Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377<script lang="ts"> import type { WebConnectionState } from "../connection"; import type { WorkspaceSummary } from "@/protocol/response"; import { createEventDispatcher, onMount, tick } from "svelte"; import { activeChangesInFlight, branchShortName, lifecycleLabel, shortWorkspaceId, workspaceDotClass, repositoryProjectName, } from "../workspace-grouping";
export let phase: WebConnectionState["phase"] = "connected"; export let createPending = false; export let failure: { code: string; message: string; retryableSessionId?: string } | null = null; export let modeHints: string[] = ["plan", "build"]; /** Optional working directory pre-filled when launching from a project context. * Read once on mount; the parent remounts this panel (via `{#key}`) to re-target it. */ export let initialCwd = ""; /** Durable workspace catalog (saga j8dtju.11). Empty for legacy daemons — identical to * the ordinary cwd launch. Drives the "Existing workspace" attach target list. */ export let workspaces: WorkspaceSummary[] = []; /** True while the parent is fetching the workspace catalog (`workspace.list`). */ export let loading = false; /** Workspace catalog fetch error, if any. Attaching remains impossible until it clears. */ export let error: string | null = null;
/** Shared optional launch fields — appended to every dispatched payload so a fresh Agent * runtime is configured consistently regardless of the chosen target. Mirrors the * `WorkspaceLaunchFields` (minus `name`, which the UI does not surface) of the protocol's * workspace.create/attach commands. */ type LaunchOpts = { title?: string; model?: string; provider?: string; mode?: string }; type CreatePayload = { cwd: string } & LaunchOpts; type CreateWorkspacePayload = { cwd: string; label?: string; branch?: string; base_ref?: string } & LaunchOpts; type AttachWorkspacePayload = { workspace_id: string } & LaunchOpts;
type Target = "current" | "worktree" | "existing";
const dispatcher = createEventDispatcher<{ /** Current directory — ordinary cwd launch. Payload is unchanged from the legacy create. */ create: CreatePayload; /** New worktree — `workspace.create`: a fresh Agent + Session in a new managed worktree. */ createWorkspace: CreateWorkspacePayload; /** Existing workspace — `workspace.attach`: a fresh Agent + Session on a durable worktree. */ attachWorkspace: AttachWorkspacePayload; cancel: null; dismissFailure: null; }>();
let target: Target = "current"; let cwd = initialCwd; let title = ""; let model = ""; let provider = ""; let mode = ""; // Worktree-only inputs. let label = ""; let branch = ""; let baseRef = ""; // Existing-workspace attach selection. let query = ""; let selectedWorkspaceId = "";
let cwdEl: HTMLInputElement; let searchEl: HTMLInputElement;
$: connected = phase === "connected"; $: trimmedCwd = cwd.trim(); $: activeWorkspaces = activeChangesInFlight(workspaces); $: filteredWorkspaces = filterWorkspaces(activeWorkspaces, query); // Submit readiness is target-dependent: current/worktree need a cwd (the launch/source dir); // existing needs a selected attachable workspace. $: canSubmit = connected && !createPending && ( target === "existing" ? selectedWorkspaceId.length > 0 : trimmedCwd.length > 0 ); $: submitLabel = target === "current" ? (createPending ? "Creating" : "Create session") : target === "worktree" ? (createPending ? "Creating" : "Create worktree") : (createPending ? "Attaching" : "Attach workspace");
onMount(() => { cwdEl?.focus(); });
function filterWorkspaces(list: readonly WorkspaceSummary[], q: string): WorkspaceSummary[] { const needle = q.trim().toLowerCase(); if (needle.length === 0) return [...list]; return list.filter((w) => w.label.toLowerCase().includes(needle) || w.cwd.toLowerCase().includes(needle) || w.workspace_id.toLowerCase().includes(needle) || (w.branch ?? "").toLowerCase().includes(needle) // The ORIGINAL project's name/path — a worktree's own cwd is a managed id, so the // project name is often the only human-meaningful search key. || (w.repository_cwd ?? "").toLowerCase().includes(needle) || (repositoryProjectName(w.repository_cwd ?? "")?.toLowerCase().includes(needle) ?? false), ); }
function buildLaunchOpts(): LaunchOpts { const opts: LaunchOpts = {}; const t = title.trim(); const m = model.trim(); const p = provider.trim(); const md = mode.trim(); if (t) opts.title = t; if (m) opts.model = m; if (p) opts.provider = p; if (md) opts.mode = md; return opts; }
async function switchTarget(next: Target): Promise<void> { // `bind:group` already updates `target`; this assignment is idempotent (a same-value // no-op in Svelte) so it is safe regardless of bind/handler ordering. The handler exists // to move focus to the primary field of the newly-revealed mode (the tab-panel pattern): // Existing hides the cwd field, so focus the search box instead. target = next; await tick(); if (target === "existing") searchEl?.focus(); else cwdEl?.focus(); }
function submit(): void { if (!canSubmit) return; const opts = buildLaunchOpts(); if (target === "worktree") { const payload: CreateWorkspacePayload = { cwd: trimmedCwd, ...opts }; const l = label.trim(); const b = branch.trim(); const br = baseRef.trim(); if (l) payload.label = l; if (b) payload.branch = b; if (br) payload.base_ref = br; dispatcher("createWorkspace", payload); } else if (target === "existing") { dispatcher("attachWorkspace", { workspace_id: selectedWorkspaceId, ...opts }); } else { dispatcher("create", { cwd: trimmedCwd, ...opts }); } }</script>
<div class="create-panel" aria-label="New session"> <header class="panel-head"> <span class="tracked">New session</span> <button class="btn btn--ghost close" type="button" on:click={() => dispatcher("cancel", null)} aria-label="Cancel new session"> <svg class="icon-x" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false"> <path d="M3.75 3.75 L12.25 12.25 M12.25 3.75 L3.75 12.25" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" /> </svg> </button> </header>
<form class="create-form" on:submit|preventDefault={submit}> <fieldset class="target-set"> <legend class="set-legend">Launch target</legend> <p class="fresh-note">Each launch starts a <strong>fresh Agent</strong> with a <strong>fresh transcript</strong>. To resume an existing session, open it from the rack.</p> <div class="target-options"> <label class="target-option" class:is-active={target === "current"}> <input type="radio" name="launch-target" value="current" bind:group={target} on:change={() => void switchTarget("current")} id="tgt-current" /> <span class="target-copy"> <span class="target-title">Current directory</span> <span class="target-desc">Launch directly in a working directory. No worktree is created.</span> </span> </label> <label class="target-option" class:is-active={target === "worktree"}> <input type="radio" name="launch-target" value="worktree" bind:group={target} on:change={() => void switchTarget("worktree")} id="tgt-worktree" /> <span class="target-copy"> <span class="target-title">New worktree</span> <span class="target-desc">Create a managed worktree and launch inside it.</span> </span> </label> <label class="target-option" class:is-active={target === "existing"}> <input type="radio" name="launch-target" value="existing" bind:group={target} on:change={() => void switchTarget("existing")} id="tgt-existing" /> <span class="target-copy"> <span class="target-title">Existing workspace</span> <span class="target-desc">Attach a fresh Agent to a durable change workspace.</span> </span> </label> </div> </fieldset>
{#if target !== "existing"} <label class="field"> <span class="field-label">{target === "worktree" ? "Source directory" : "Working directory"} <span class="req" aria-hidden="true">*</span></span> <input class="cwd mono" type="text" bind:value={cwd} bind:this={cwdEl} placeholder="/home/you/project" autocomplete="off" spellcheck="false" required aria-required="true" aria-describedby={failure ? "session-create-failure" : "cwd-hint"} /> </label> {/if}
{#if target === "worktree"} <label class="field"> <span class="field-label">Workspace label <em>optional</em></span> <input class="ws-label mono" type="text" bind:value={label} placeholder="feature-auth" autocomplete="off" /> </label> <label class="field"> <span class="field-label">Branch <em>optional</em></span> <input class="ws-branch mono" type="text" bind:value={branch} placeholder="feature/auth" autocomplete="off" spellcheck="false" /> </label> <label class="field"> <span class="field-label">Base ref <em>optional</em></span> <input class="ws-base mono" type="text" bind:value={baseRef} placeholder="main" list="base-hints" autocomplete="off" spellcheck="false" /> <datalist id="base-hints">{#each ["main", "master", "develop"] as hint (hint)}<option value={hint}></option>{/each}</datalist> </label> {/if}
{#if target === "existing"} <div class="ws-attach" role="group" aria-label="Existing workspace"> <label class="field ws-search-field"> <span class="field-label">Search workspaces</span> <input class="ws-search mono" type="text" bind:value={query} bind:this={searchEl} placeholder="Label, branch, or path" autocomplete="off" spellcheck="false" /> </label>
{#if loading} <p class="ws-state mono" role="status">Loading workspaces…</p> {:else if error} <p class="ws-state ws-state--error mono" role="status">{error}</p> {:else if activeWorkspaces.length === 0} <p class="ws-state mono">No active workspaces.</p> {:else if filteredWorkspaces.length === 0} <p class="ws-state mono">No workspaces match your search.</p> {:else} <div class="ws-list" role="radiogroup" aria-label="Active workspaces"> {#each filteredWorkspaces as w (w.workspace_id)} <label class="ws-item" class:is-selected={selectedWorkspaceId === w.workspace_id}> <input type="radio" name="workspace-id" value={w.workspace_id} bind:group={selectedWorkspaceId} /> <span class={workspaceDotClass(w)} aria-hidden="true"></span> <span class="ws-item-main"> <span class="ws-item-label">{w.label}</span> <span class="ws-item-meta mono"> <span class="ws-branch">{branchShortName(w.branch)}</span> {#if w.repository_cwd} <span class="ws-sep" aria-hidden="true">·</span> <span class="ws-repo" title={w.repository_cwd}>{repositoryProjectName(w.repository_cwd)}</span> {/if} <span class="ws-sep" aria-hidden="true">·</span> <span class="ws-id">{shortWorkspaceId(w.workspace_id)}</span> </span> <span class="ws-item-path mono" title={w.cwd}>{w.cwd}</span> </span> <span class="ws-item-counts mono"> {#if w.live_agent_count > 0} <span class="ws-count">{w.live_agent_count} live{w.live_agent_count > 1 ? " writers" : " writer"}</span> {/if} <span class="ws-count ws-life">{lifecycleLabel(w.lifecycle)}</span> </span> </label> {/each} </div> {/if} </div> {/if}
<fieldset class="opts-set"> <legend class="set-legend">Launch options <em>optional</em></legend> <label class="field"> <span class="field-label">Title <em>optional</em></span> <input class="title" type="text" bind:value={title} placeholder="Release prep" autocomplete="off" /> </label> <label class="field"> <span class="field-label">Model <em>optional</em></span> <input class="model mono" type="text" bind:value={model} placeholder="claude-sonnet" autocomplete="off" /> </label> <label class="field"> <span class="field-label">Provider <em>optional</em></span> <input class="provider" type="text" bind:value={provider} placeholder="anthropic" autocomplete="off" /> </label> <label class="field"> <span class="field-label">Mode <em>optional</em></span> <input class="mode mono" type="text" bind:value={mode} placeholder="plan" list="mode-hints" autocomplete="off" /> <datalist id="mode-hints">{#each modeHints as hint (hint)}<option value={hint}></option>{/each}</datalist> </label> </fieldset>
{#if target === "current"} <p class="hint mono" id="cwd-hint">Launches a fresh Agent in this directory with a fresh transcript.</p> {:else if target === "worktree"} <p class="hint mono" id="cwd-hint">Creates a worktree from the source directory and launches a fresh Agent inside it.</p> {/if}
{#if failure} <div class="failure" id="session-create-failure" role="status" aria-live="polite"> <div class="failure-row"> <span class="sev-dot is-error" aria-hidden="true"></span> <span class="failure-msg mono">{failure.message}</span> <button class="btn btn--ghost dismiss" type="button" on:click={() => dispatcher("dismissFailure", null)} aria-label="Dismiss failure notice"> <svg class="icon-x" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false"> <path d="M3.75 3.75 L12.25 12.25 M12.25 3.75 L3.75 12.25" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" /> </svg> </button> </div> {#if failure.retryableSessionId} <p class="retained mono">Session retained: {failure.retryableSessionId}</p> {/if} </div> {/if}
<div class="form-actions"> <button class="btn btn--primary" type="submit" disabled={!canSubmit}>{submitLabel}</button> <button class="btn btn--ghost" type="button" on:click={() => dispatcher("cancel", null)} disabled={createPending}>Cancel</button> </div> </form></div>
<style> .create-panel { display: flex; flex-direction: column; height: 100%; min-height: 0; background: var(--surface); border-left: 1px solid var(--hairline); box-shadow: var(--shadow-lift); } .panel-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-bottom: 1px solid var(--hairline); flex: none; } .panel-head .tracked { color: var(--ink-2); } .close { padding: 2px 8px; display: inline-flex; align-items: center; justify-content: center; } .icon-x { display: block; }
.create-form { display: flex; flex-direction: column; gap: 12px; padding: 16px 14px 18px; overflow-y: auto; min-height: 0; }
/* ── launch target fieldset ── */ .target-set, .opts-set { border: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; } .set-legend { padding: 0; font-size: 11px; color: var(--ink-3); } .set-legend em { color: var(--ink-mute); font-style: normal; } .fresh-note { font-size: 11px; line-height: 1.45; color: var(--ink-3); margin: 0 0 2px; } .fresh-note strong { color: var(--ink-2); font-weight: 600; } .target-options { display: flex; flex-direction: column; gap: 6px; } .target-option { display: flex; align-items: flex-start; gap: 8px; padding: 8px 10px; border: 1px solid var(--hairline); border-radius: var(--r-sm); background: var(--canvas); cursor: pointer; } .target-option:hover { border-color: var(--hairline-strong); background: var(--surface-hover); } .target-option.is-active { border-color: var(--accent); background: var(--accent-soft); } .target-option input[type="radio"] { width: auto; margin-top: 2px; flex: none; accent-color: var(--accent); } .target-copy { display: flex; flex-direction: column; gap: 1px; min-width: 0; } .target-title { font-size: 13px; color: var(--ink); } .target-desc { font-size: 11px; color: var(--ink-3); line-height: 1.4; }
/* ── shared fields ── */ .field { display: flex; flex-direction: column; gap: 4px; } .field-label { font-size: 11px; color: var(--ink-3); } .field-label em { color: var(--ink-mute); font-style: normal; } .field-label .req { color: var(--sev-error-ink); } input { font: inherit; border: 1px solid var(--hairline); border-radius: var(--r-sm); padding: 8px 10px; background: var(--canvas); color: var(--ink); width: 100%; } input:focus-visible { box-shadow: var(--focus-ring); } .mono { font-family: var(--font-mono); font-size: 13px; }
/* ── existing-workspace attach ── */ .ws-attach { display: flex; flex-direction: column; gap: 8px; } .ws-list { display: flex; flex-direction: column; gap: 4px; max-height: 240px; overflow-y: auto; padding: 2px; border: 1px solid var(--hairline); border-radius: var(--r-sm); background: var(--surface-sunken); } .ws-state { color: var(--ink-3); font-size: 12px; margin: 0; padding: 12px 10px; text-align: center; } .ws-state--error { color: var(--sev-error-ink); } .ws-item { display: flex; align-items: center; gap: 8px; padding: 7px 10px; border: 1px solid transparent; border-radius: var(--r-sm); background: var(--canvas); cursor: pointer; } .ws-item:hover { border-color: var(--hairline); background: var(--surface-hover); } .ws-item.is-selected { border-color: var(--accent); background: var(--accent-soft); } .ws-item input[type="radio"] { width: auto; flex: none; accent-color: var(--accent); } .ws-item-main { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1; } .ws-item-label { font-size: 12.5px; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ws-item-meta { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--ink-3); } .ws-sep { color: var(--ink-mute); } /* The original project's name — how a managed worktree stays attributable to its repo. */ .ws-repo { color: var(--ink-2); white-space: nowrap; } .ws-item-path { font-size: 10.5px; color: var(--ink-mute); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ws-item-counts { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; flex: none; } .ws-count { font-size: 10.5px; color: var(--ink-3); white-space: nowrap; } .ws-count.ws-life { color: var(--sev-success-ink); }
/* ── hints / failure / actions ── */ .hint, .retained { color: var(--ink-3); font-size: 11px; margin: 0; } .failure { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--sev-error) 35%, transparent); border-radius: var(--r-sm); background: var(--sev-error-bg); } .failure-row { display: flex; align-items: flex-start; gap: 8px; } .failure-msg { font-size: 12px; color: var(--sev-error-ink); flex: 1; word-break: break-word; } .dismiss { padding: 0 6px; flex: none; display: inline-flex; align-items: center; justify-content: center; } .form-actions { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
/* ── responsive: tighten the target options on very narrow drawers ── */ @media (max-width: 360px) { .create-form { padding: 12px 10px 14px; } .target-desc { display: none; } }</style>