From 920237d7d40f376088be40f03bbf89135c55642b Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Sat, 9 May 2026 19:00:41 -0400 Subject: [PATCH 1/8] feat: add queued worktree intents --- README.md | 8 +- packages/client/src/App.tsx | 72 +++++++- packages/client/src/MobileChromeSheet.tsx | 19 ++- .../canvas/workspace-switcher/Collapsed.tsx | 6 +- .../canvas/workspace-switcher/SearchPanel.tsx | 156 +++++++++++++++++- .../workspace-switcher/WorkspaceSwitcher.tsx | 15 +- .../canvas/workspace-switcher/model.test.ts | 68 ++++++++ .../src/canvas/workspace-switcher/model.ts | 66 ++++++-- packages/client/src/commands.ts | 104 +++++++++++- .../src/right-panel/MetadataInspector.tsx | 13 ++ packages/client/src/terminal/TerminalMeta.tsx | 24 ++- .../client/src/terminal/useSessionRestore.ts | 1 + .../client/src/terminal/useTerminalCrud.ts | 12 ++ .../client/src/terminal/useWorktreeOps.ts | 25 ++- packages/client/src/wire.ts | 17 ++ packages/common/src/contract.ts | 6 + packages/common/src/surface.ts | 27 +++ packages/server/src/router.ts | 8 + packages/server/src/session.test.ts | 20 +++ packages/server/src/state.test.ts | 4 +- packages/server/src/state.ts | 14 +- packages/server/src/surface.ts | 4 + packages/server/src/terminals.ts | 12 ++ packages/surface/README.md | 5 +- 24 files changed, 659 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 0f74891bf..4b5717ac7 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ Open http://127.0.0.1:7681 (or the address you chose above). - Command palette (Cmd/Ctrl+K) — search terminals, switch themes, run actions - Worktree-naming flow — drilling into `New terminal → ` opens a leaf with the worktree name pre-filled (random ADJ-NOUN, auto-selected) and an agent picker below; type a custom name and hit Enter to land in a freshly-branched worktree, or pick an agent to launch it in one step. The typed name becomes the branch name and surfaces verbatim on the workspace-switcher pill, so worktrees stay identifiable at a glance +- Terminal intent — `Set terminal intent` attaches a short user-authored note to a live terminal. It persists with the session and appears in the tile title bar, inspector, mobile sheet, and workspace switcher search/cards +- Queued worktrees — `Queue worktree → ` stores a repo + intent as a durable backlog item without opening a terminal. The workspace switcher's `Queued` column can later start it as a worktree; the shell/agent choice happens at start time, not when the item is queued - Agent-aware command palette — once you've run a known agent CLI (`claude`, `aider`, `opencode`, `codex`, `goose`, `gemini`, `cursor-agent`) in any kolu terminal, it surfaces in two places: as a row in the worktree-naming leaf above (so the same Enter creates the worktree and launches the agent), and under `Debug → Recent agents` as a prefill-into-active-terminal affordance. Prompt/message flag values (`-p`/`--prompt`/`-m`/`--message`) are stripped before storage so ephemeral prompt text never lands in the persisted MRU - Workspace-switcher pings — when an agent is waiting on you (or has finished with an unread completion), its collapsed switcher pill pulses an alert dot so you can spot it without panning. Ctrl+Tab (or Alt+Tab) cycles terminals in MRU order: hold the modifier, press Tab to advance, release to commit - Keyboard-driven — Cmd+T new terminal, Cmd+1Cmd+9 jump, Cmd+Shift+[ / Cmd+Shift+] cycle, Cmd+/ shortcuts help @@ -53,7 +55,7 @@ The desktop workspace is mode-less — every terminal renders as a draggable, re - **Infinite pan & zoom** — two-finger scroll / trackpad to pan, pinch or Ctrl+scroll to zoom. Hold Shift to force pan even with the cursor over a terminal tile (hand-tool style). No boundaries — the canvas extends freely in every direction via CSS `transform: translate() scale()` (Figma/Excalidraw model) - **Snap-to-grid** — tiles snap to a 24px grid on drag and resize for tidy layouts - **Maximize a tile** — double-click any tile's title bar (or click the maximize button) to fill the viewport; the maximized posture persists across reload via localStorage so you land back where you left off -- **Floating workspace switcher** — a compact repo/branch pill strip sits at the top of the canvas, ghosted at rest and behind any tile that overlaps it; hover opens a searchable panel grouped by live agent state (`Awaiting you`, `Working`, `No agent`) with repo facets. Ordering is **agent recency** (most recent agent semantic-key transition first) with canvas position as the secondary key. The collapsed pill strip always keeps the active-agent terminals plus the user's currently-active terminal in view, then fills remaining slots with up to five idle peers per repo. Repo positions in the strip are alphabetical so they don't shuffle as agents come and go; pills within a repo follow recency order. Recency is persisted, so a restored session lands where it left off. Click a pill or card to focus and center its tile +- **Floating workspace switcher** — a compact repo/branch pill strip sits at the top of the canvas, ghosted at rest and behind any tile that overlaps it; hover opens a searchable panel grouped by live agent state (`Awaiting you`, `Working`, `No agent`) with repo facets, plus a `Queued` column for backlog worktrees. Ordering is **agent recency** (most recent agent semantic-key transition first) with canvas position as the secondary key. The collapsed pill strip always keeps the active-agent terminals plus the user's currently-active terminal in view, then fills remaining slots with up to five idle peers per repo. Repo positions in the strip are alphabetical so they don't shuffle as agents come and go; pills within a repo follow recency order. Recency is persisted, so a restored session lands where it left off. Click a pill or card to focus and center its tile; start a queued card to create its worktree terminal - **Switcher border encodes state** — each collapsed pill or panel card border doubles as identity (repo color) and live status: a conic-gradient sweep while the agent is `thinking`/`tool_use`, a breathing pulse while `waiting`, a static ring when the terminal is just active, and an inset glow when the active tile also has a working agent - **Auto-park stale terminals** — when a terminal's last observed agent transition is more than 4 hours old, its switcher pill, panel card, and canvas tile fade and the agent-state border drops, so a wall of "awaiting" tiles you parked yesterday doesn't drown out the one that genuinely needs you now. It also stops counting toward the awaiting bucket and is dropped from the OS/PWA dock badge. Any fresh agent transition unparks automatically — pure derivation off `lastActivityAt`, no persisted state - **Minimap awaiting heatmap** — the canvas minimap dots any tile whose agent is currently `waiting` (and not auto-parked) with a small alert indicator, so you can scan a 20-tile workspace for "who needs me" without opening the switcher @@ -267,9 +269,9 @@ flowchart TB [^client-state]: Local-only view state (active terminal, MRU order, attention flags) lives in SolidJS [signals and stores](https://docs.solidjs.com/reference/store-utilities/create-store) inside singleton `useXxx.ts` modules — separate from server-derived subscription state. -**Persistence** — sessions auto-save to `~/.config/kolu/state.json` via [`conf`](https://github.com/sindresorhus/conf), debounced at 500 ms[^persistence]. +**Persistence** — sessions and queued worktrees auto-save to `~/.config/kolu/state.json` via [`conf`](https://github.com/sindresorhus/conf), debounced at 500 ms for terminal snapshots[^persistence]. -[^persistence]: Schema is versioned with explicit migrations. Stores CWD, sort order, and parent relationships per terminal. +[^persistence]: Schema is versioned with explicit migrations. Terminal snapshots store CWD, git metadata, theme, intent, parent relationships, layout, sub-panel state, and agent recency; queued worktrees store repo path, optional worktree name, intent, and creation time. [PartySocket](https://docs.partykit.io/reference/partysocket-api/) handles WebSocket auto-reconnect; `@kolu/surface/solid`'s `surfaceClient` (wired up in `packages/client/src/wire.ts`) installs oRPC's [`ClientRetryPlugin`](https://orpc.dev/docs/plugins/client-retry) so every Cell/Collection/Stream/Event hook (and the `streamCall` escape hatch for raw streams like terminal `attach`) transparently re-subscribes after a drop — every server-side streaming handler is already snapshot-then-deltas and the reducer in `useTerminalMetadata.ts` pattern-matches an `ActivityStreamEvent` discriminated union (`snapshot` replaces, `delta` appends) so re-subscribe resume is structural, not defensive. Transport events (`connecting` / `connected` / `disconnected` / `reconnected` / `restarted`) are exposed as a single `ServerLifecycleEvent` signal in `packages/client/src/rpc/rpc.ts`, and `TransportOverlay` pattern-matches it into one dim-backdrop card: `disconnected` shows "Reconnecting…" (the backdrop is pointer-events-none, so users can still scroll and read buffers underneath), and `restarted` swaps to "Server updated" with the Reload button inline in the card. diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx index 4e88ccb7e..6a903ea91 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -8,7 +8,8 @@ import Dialog from "@corvu/dialog"; import { Meta, Title } from "@solidjs/meta"; import type { ServerIdentity } from "kolu-common/contract"; -import type { TerminalId } from "kolu-common/surface"; +import type { QueuedWorktree, TerminalId } from "kolu-common/surface"; +import { randomName } from "memorable-names"; import { type Component, createEffect, @@ -44,7 +45,12 @@ import { useRecorder } from "./recorder/useRecorder"; import WebcamOverlay from "./recorder/WebcamOverlay"; import RightPanelLayout from "./right-panel/RightPanelLayout"; import { useRightPanel } from "./right-panel/useRightPanel"; -import { client } from "./wire"; +import { + client, + queuedWorktrees, + recentAgents, + setQueuedWorktrees, +} from "./wire"; import { serverProcessId, wsStatus } from "./rpc/rpc"; import TransportOverlay from "./rpc/TransportOverlay"; import ShortcutsHelp from "./ShortcutsHelp"; @@ -178,6 +184,46 @@ const App: Component = () => { if (id) store.activate(id); } + function queueWorktree(repoPath: string, intent: string) { + const trimmed = intent.trim(); + if (!trimmed) return; + const item: QueuedWorktree = { + id: crypto.randomUUID(), + repoPath, + intent: trimmed, + createdAt: Date.now(), + }; + setQueuedWorktrees([...queuedWorktrees(), item]); + } + + function deleteQueuedWorktree(id: string) { + setQueuedWorktrees(queuedWorktrees().filter((q) => q.id !== id)); + } + + async function startQueuedWorktree( + id: string, + name: string, + initialCommand?: string, + ) { + const item = queuedWorktrees().find((q) => q.id === id); + if (!item) return; + const terminalId = await worktree.handleCreateWorktree( + item.repoPath, + name.trim(), + { + initialCommand, + initial: { intent: item.intent }, + }, + ); + if (terminalId) deleteQueuedWorktree(id); + } + + function handleSetTerminalIntent(intent?: string) { + const id = store.activeId(); + if (!id) return; + crud.setIntent(id, intent); + } + const arrange = useCanvasArrange({ store, crud, @@ -270,8 +316,14 @@ const App: Component = () => { handleSetTheme, setAboutOpen, setDiagnosticInfoOpen, - handleCreateWorktree: (repoPath, name, initialCommand) => - void worktree.handleCreateWorktree(repoPath, name, initialCommand), + handleCreateWorktree: (repoPath, name, options) => + void worktree.handleCreateWorktree(repoPath, name, options), + queuedWorktrees, + queueWorktree, + startQueuedWorktree: (id, name, initialCommand) => + void startQueuedWorktree(id, name, initialCommand), + deleteQueuedWorktree, + handleSetTerminalIntent, handleClose: () => { const id = store.activeId(); if (id) closeTerminal(id); @@ -471,10 +523,22 @@ const App: Component = () => { workspaceSwitcher={ a.command)} activeId={store.activeId()} getRecency={recencyOf} openRequest={workspaceSwitcherOpenRequest()} onSelect={store.activate} + onStartQueuedWorktree={(id, initialCommand) => { + const item = queuedWorktrees().find((q) => q.id === id); + if (!item) return; + void startQueuedWorktree( + id, + item.worktreeName ?? randomName(), + initialCommand, + ); + }} + onDeleteQueuedWorktree={deleteQueuedWorktree} onCreate={() => openPaletteGroup("New terminal")} /> } diff --git a/packages/client/src/MobileChromeSheet.tsx b/packages/client/src/MobileChromeSheet.tsx index 892d7b71e..67bc90ea0 100644 --- a/packages/client/src/MobileChromeSheet.tsx +++ b/packages/client/src/MobileChromeSheet.tsx @@ -113,11 +113,20 @@ const MobileChromeSheet: Component<{ > └─ - - {item.label} + + + {item.label} + + + {(intent) => ( + + {intent()} + + )} + diff --git a/packages/client/src/canvas/workspace-switcher/Collapsed.tsx b/packages/client/src/canvas/workspace-switcher/Collapsed.tsx index b5a52a495..42da2632a 100644 --- a/packages/client/src/canvas/workspace-switcher/Collapsed.tsx +++ b/packages/client/src/canvas/workspace-switcher/Collapsed.tsx @@ -119,7 +119,11 @@ const CollapsedWorkspaceSwitcher: Component<{ : {}), }} onClick={() => props.onSelect(item().id)} - title={item().info.meta.cwd} + title={ + item().info.meta.intent + ? `${item().info.meta.intent} - ${item().info.meta.cwd}` + : item().info.meta.cwd + } > void; onRepoFilterChange: (repoName: string | null) => void; onSelect: (id: TerminalId) => void; + recentAgentCommands: string[]; + onStartQueuedWorktree: (id: string, agentCommand?: string) => void; + onDeleteQueuedWorktree: (id: string) => void; onClose: () => void; }> = (props) => { const store = useTerminalStore(); const tileTheme = useTileTheme(); - const columnCount = () => Math.max(1, props.model.columns.length); + const columnCount = () => + Math.max( + 1, + props.model.columns.length + + (props.model.queuedWorktrees.length > 0 ? 1 : 0), + ); const totalCount = () => props.model.repoFacets.reduce((sum, facet) => sum + facet.count, 0); let searchInputRef: HTMLInputElement | undefined; @@ -83,7 +92,7 @@ const WorkspaceSearchPanel: Component<{ value={props.query} onInput={(e) => props.onQueryChange(e.currentTarget.value)} class="flex-1 min-w-0 bg-transparent border-0 outline-none font-mono text-[0.8rem] text-fg placeholder:text-fg-3/60 caret-accent" - placeholder="repo, branch, pr, agent, cwd…" + placeholder="repo, intent, branch, pr, agent, cwd..." aria-label="Search workspaces" spellcheck={false} autocomplete="off" @@ -149,6 +158,54 @@ const WorkspaceSearchPanel: Component<{ "grid-template-columns": `repeat(${columnCount()}, minmax(0, 1fr))`, }} > + 0}> +
+
+
+ Queued +
+
+ {props.model.visibleQueuedWorktrees.length + .toString() + .padStart(2, "0")} +
+
+
+ 0} + fallback={ +
+ -- no queued worktrees match -- +
+ } + > + + {(queued) => ( + + props.onStartQueuedWorktree( + queued().id, + agentCommand, + ) + } + onDelete={() => + props.onDeleteQueuedWorktree(queued().id) + } + /> + )} + +
+
+
+
{(column) => (
- +
- ── no live terminals match ── + -- no workspaces match --
@@ -212,6 +274,72 @@ const WorkspaceSearchPanel: Component<{ ); }; +const QueuedWorktreeCard: Component<{ + queued: WorkspaceSwitcherQueuedWorktree; + recentAgentCommands: string[]; + onStart: (agentCommand?: string) => void; + onDelete: () => void; +}> = (props) => { + const agentCommands = () => props.recentAgentCommands.slice(0, 3); + return ( +
+
+ + {props.queued.repoName} + + +
+
+ {props.queued.intent} +
+
+ {props.queued.worktreeName ?? "name chosen on start"} +
+
+ + + {(command) => ( + + )} + +
+
+ ); +}; + /** Sidebar facet row — left accent bar in repo color when selected, * no fill. Count uses tabular nums so the column reads vertically. */ const RepoFacetButton: Component<{ @@ -306,7 +434,11 @@ const WorkspaceCard: Component<{ "--pill-border-radius": "calc(0.5rem + 2px)", }} onClick={() => props.onSelect()} - title={props.entry.info.meta.cwd} + title={ + props.entry.info.meta.intent + ? `${props.entry.info.meta.intent} - ${props.entry.info.meta.cwd}` + : props.entry.info.meta.cwd + } > + + {(intent) => ( +
+ {intent()} +
+ )} +
+ {/* Status: glyph color encodes bucket; agent label and tokens sit * on the same line for left-edge scanability. */}
diff --git a/packages/client/src/canvas/workspace-switcher/WorkspaceSwitcher.tsx b/packages/client/src/canvas/workspace-switcher/WorkspaceSwitcher.tsx index 2175242cf..5a33d2a1e 100644 --- a/packages/client/src/canvas/workspace-switcher/WorkspaceSwitcher.tsx +++ b/packages/client/src/canvas/workspace-switcher/WorkspaceSwitcher.tsx @@ -17,7 +17,7 @@ * The chrome bar fades in a frosted surface across the whole header * during engagement so the strip and panel read as one floating piece. */ -import type { TerminalId } from "kolu-common/surface"; +import type { QueuedWorktree, TerminalId } from "kolu-common/surface"; import { type Component, createEffect, @@ -42,6 +42,8 @@ import { /** Controller that owns query/filter state and composes both switcher views. */ const WorkspaceSwitcher: Component<{ entries: WorkspaceSwitcherSourceEntry[]; + queuedWorktrees: QueuedWorktree[]; + recentAgentCommands: string[]; /** Active terminal id — kept in the collapsed pill strip even if its * repo's idle cap would otherwise hide it. */ activeId: TerminalId | null; @@ -51,6 +53,8 @@ const WorkspaceSwitcher: Component<{ openRequest: number; /** Click handler — caller decides whether to pan, swap active, etc. */ onSelect: (id: TerminalId) => void; + onStartQueuedWorktree: (id: string, agentCommand?: string) => void; + onDeleteQueuedWorktree: (id: string) => void; /** Open the "new terminal" flow. */ onCreate: () => void; }> = (props) => { @@ -70,6 +74,7 @@ const WorkspaceSwitcher: Component<{ activeId: props.activeId, getRecency: props.getRecency, isStale, + queuedWorktrees: props.queuedWorktrees, }), ); @@ -145,6 +150,11 @@ const WorkspaceSwitcher: Component<{ closePanel(); }; + const startQueuedAndClose = (id: string, agentCommand?: string) => { + props.onStartQueuedWorktree(id, agentCommand); + closePanel(); + }; + return (
setFocusSearchOnOpen(false)} onRepoFilterChange={setRepoFilter} onSelect={selectAndClose} + recentAgentCommands={props.recentAgentCommands} + onStartQueuedWorktree={startQueuedAndClose} + onDeleteQueuedWorktree={props.onDeleteQueuedWorktree} onClose={closePanel} />
diff --git a/packages/client/src/canvas/workspace-switcher/model.test.ts b/packages/client/src/canvas/workspace-switcher/model.test.ts index 52cd8b1ea..0c7354bbc 100644 --- a/packages/client/src/canvas/workspace-switcher/model.test.ts +++ b/packages/client/src/canvas/workspace-switcher/model.test.ts @@ -419,4 +419,72 @@ describe("buildWorkspaceSwitcherModel", () => { modelFor(entries, { query: "claude sonnet" }).visibleEntries, ).toHaveLength(1); }); + + it("searches terminal intent", () => { + const model = modelFor( + [ + source("intent-terminal", { + intent: "Review queued worktree handoff", + }), + source("ordinary-terminal", { + git: makeGit({ repoName: "kolu", branch: "ordinary" }), + }), + ], + { query: "handoff" }, + ); + + expect(model.visibleEntries.map((entry) => entry.id)).toEqual([ + "intent-terminal", + ]); + }); + + it("includes queued worktrees in search and repo facets", () => { + const model = modelFor([], { + query: "handoff", + queuedWorktrees: [ + { + id: "00000000-0000-4000-8000-000000000001", + repoPath: "/home/user/kolu", + intent: "Review queued worktree handoff", + createdAt: 1, + }, + ], + }); + + expect(model.visibleEntries).toEqual([]); + expect(model.visibleQueuedWorktrees.map((q) => q.id)).toEqual([ + "00000000-0000-4000-8000-000000000001", + ]); + expect(model.repoFacets).toEqual([ + { repoName: "kolu", count: 1, color: "var(--color-accent)" }, + ]); + }); + + it("filters queued worktrees by selected repo", () => { + const model = modelFor( + [ + source("live", { + git: makeGit({ repoName: "kolu", branch: "main" }), + }), + ], + { + repoFilter: "emanote", + queuedWorktrees: [ + { + id: "00000000-0000-4000-8000-000000000002", + repoPath: "/home/user/emanote", + intent: "Draft docs backlog item", + worktreeName: "docs-backlog", + createdAt: 1, + }, + ], + }, + ); + + expect(model.selectedRepo).toBe("emanote"); + expect(model.visibleEntries).toEqual([]); + expect(model.visibleQueuedWorktrees.map((q) => q.intent)).toEqual([ + "Draft docs backlog item", + ]); + }); }); diff --git a/packages/client/src/canvas/workspace-switcher/model.ts b/packages/client/src/canvas/workspace-switcher/model.ts index 34042e438..42ec52696 100644 --- a/packages/client/src/canvas/workspace-switcher/model.ts +++ b/packages/client/src/canvas/workspace-switcher/model.ts @@ -1,8 +1,10 @@ import type { AgentInfo, + QueuedWorktree, TerminalId, TerminalMetadata, } from "kolu-common/surface"; +import { cwdBasename } from "kolu-common/path"; import type { TerminalDisplayInfo } from "../../terminal/terminalDisplay"; import type { TileLayout } from "../TileLayout"; @@ -131,6 +133,12 @@ export type WorkspaceRepoFacet = { color: string; }; +/** Queued worktree projection rendered separately from live terminals. */ +export type WorkspaceSwitcherQueuedWorktree = QueuedWorktree & { + repoName: string; + searchText: string; +}; + /** Agent bucket plus the entries currently visible in that column. * `nonStaleCount` is the active subset — entries whose last observed * agent transition is recent enough to count toward the visible badge. @@ -145,8 +153,10 @@ export type WorkspaceSwitcherColumn = /** Complete derived model for collapsed and expanded switcher renderers. */ export type WorkspaceSwitcherModel = { entries: WorkspaceSwitcherEntry[]; + queuedWorktrees: WorkspaceSwitcherQueuedWorktree[]; compactGroups: WorkspaceSwitcherRepoGroup[]; visibleEntries: WorkspaceSwitcherEntry[]; + visibleQueuedWorktrees: WorkspaceSwitcherQueuedWorktree[]; selectedRepo: string | null; repoFacets: WorkspaceRepoFacet[]; columns: WorkspaceSwitcherColumn[]; @@ -241,6 +251,7 @@ function searchTextFor(entry: { add(values, entry.suffix); add(values, info.meta.cwd); + add(values, info.meta.intent); add(values, info.meta.lastAgentCommand); add(values, git?.repoRoot); add(values, git?.repoName); @@ -266,12 +277,18 @@ function queryTokens(query: string): string[] { } function matchesQuery( - entry: WorkspaceSwitcherEntry, + entry: { searchText: string }, tokens: string[], ): boolean { return tokens.every((token) => entry.searchText.includes(token)); } +function queuedSearchTextFor(q: WorkspaceSwitcherQueuedWorktree): string { + const values: string[] = [q.repoName, q.repoPath, q.intent]; + add(values, q.worktreeName); + return values.join(" ").toLowerCase(); +} + /** Cap on idle (no-agent, non-active) compact pills per repo. Pills that * carry an active agent OR represent the user's active terminal bypass * the cap entirely — both are guaranteed reachable from the pill strip @@ -351,6 +368,7 @@ export function buildWorkspaceSwitcherModel( activeId?: TerminalId | null; getRecency?: (id: TerminalId) => number; isStale?: (lastActivityAt: number) => boolean; + queuedWorktrees?: QueuedWorktree[]; } = {}, ): WorkspaceSwitcherModel { const ordered = options.getRecency @@ -370,12 +388,20 @@ export function buildWorkspaceSwitcherModel( searchText: searchTextFor(base), }; }); + const queuedWorktrees: WorkspaceSwitcherQueuedWorktree[] = ( + options.queuedWorktrees ?? [] + ).map((q) => { + const base = { ...q, repoName: cwdBasename(q.repoPath), searchText: "" }; + return { ...base, searchText: queuedSearchTextFor(base) }; + }); - const { repoFacets, selectedRepo, visibleEntries } = searchResults( - entries, - options.query ?? "", - options.repoFilter ?? null, - ); + const { repoFacets, selectedRepo, visibleEntries, visibleQueuedWorktrees } = + searchResults( + entries, + queuedWorktrees, + options.query ?? "", + options.repoFilter ?? null, + ); const isStale = options.isStale; const columns = WORKSPACE_AGENT_BUCKETS.map((bucket) => { @@ -393,8 +419,10 @@ export function buildWorkspaceSwitcherModel( return { entries, + queuedWorktrees, compactGroups: compactGroupsFor(entries, options.activeId ?? null), visibleEntries, + visibleQueuedWorktrees, selectedRepo, repoFacets, columns, @@ -409,30 +437,39 @@ export function buildWorkspaceSwitcherModel( * silent reordering bug. */ function searchResults( entries: WorkspaceSwitcherEntry[], + queuedWorktrees: WorkspaceSwitcherQueuedWorktree[], query: string, repoFilter: string | null, ): { repoFacets: WorkspaceRepoFacet[]; selectedRepo: string | null; visibleEntries: WorkspaceSwitcherEntry[]; + visibleQueuedWorktrees: WorkspaceSwitcherQueuedWorktree[]; } { const tokens = queryTokens(query); const queryMatches = tokens.length === 0 ? entries : entries.filter((entry) => matchesQuery(entry, tokens)); + const queuedMatches = + tokens.length === 0 + ? queuedWorktrees + : queuedWorktrees.filter((q) => matchesQuery(q, tokens)); const facetCounts = new Map(); - for (const entry of queryMatches) { - const facet = facetCounts.get(entry.repoName); + const addFacet = (repoName: string, color: string) => { + const facet = facetCounts.get(repoName); if (facet) { facet.count += 1; } else { - facetCounts.set(entry.repoName, { - count: 1, - color: entry.info.repoColor, - }); + facetCounts.set(repoName, { count: 1, color }); } + }; + for (const entry of queryMatches) { + addFacet(entry.repoName, entry.info.repoColor); + } + for (const q of queuedMatches) { + addFacet(q.repoName, "var(--color-accent)"); } const repoFacets = [...facetCounts.entries()].map( ([repoName, { count, color }]) => ({ @@ -447,6 +484,9 @@ function searchResults( const visibleEntries = selectedRepo ? queryMatches.filter((entry) => entry.repoName === selectedRepo) : queryMatches; + const visibleQueuedWorktrees = selectedRepo + ? queuedMatches.filter((q) => q.repoName === selectedRepo) + : queuedMatches; - return { repoFacets, selectedRepo, visibleEntries }; + return { repoFacets, selectedRepo, visibleEntries, visibleQueuedWorktrees }; } diff --git a/packages/client/src/commands.ts b/packages/client/src/commands.ts index d3a35fa6b..1aa307ce2 100644 --- a/packages/client/src/commands.ts +++ b/packages/client/src/commands.ts @@ -1,6 +1,10 @@ /** Command palette registry — declarative list of all app-level actions. */ -import type { RecentAgent } from "kolu-common/surface"; +import type { + InitialTerminalMetadata, + QueuedWorktree, + RecentAgent, +} from "kolu-common/surface"; import { WorktreeNameSchema } from "kolu-git/schemas"; import { randomName } from "memorable-names"; import type { Accessor } from "solid-js"; @@ -27,6 +31,10 @@ function validateWorktreeName(name: string): string | null { return result.error.issues[0]?.message ?? "Invalid worktree name"; } +function validateIntent(intent: string): string | null { + return intent.trim().length > 0 ? null : "Intent is required"; +} + /** PaletteItems listing each recent agent command. Used by the Debug → * "Recent agents" entry (phase 1 prefill flow). */ function agentItems( @@ -84,8 +92,20 @@ export interface CommandDeps extends ActionContext { handleCreateWorktree: ( repoPath: string, name: string, + options?: { + initialCommand?: string; + initial?: InitialTerminalMetadata; + }, + ) => void; + queuedWorktrees: Accessor; + queueWorktree: (repoPath: string, intent: string) => void; + startQueuedWorktree: ( + id: string, + name: string, initialCommand?: string, ) => void; + deleteQueuedWorktree: (id: string) => void; + handleSetTerminalIntent: (intent?: string) => void; handleClose: () => void; // Debug simulateAlert: () => void; @@ -116,7 +136,9 @@ export function createCommands(deps: CommandDeps): Accessor { onSubmit: (name, selected) => { const agentCmd = typeof selected.data === "string" ? selected.data : undefined; - deps.handleCreateWorktree(r.repoRoot, name.trim(), agentCmd); + deps.handleCreateWorktree(r.repoRoot, name.trim(), { + initialCommand: agentCmd, + }); }, children: (): (PaletteLabel | PaletteHint)[] => worktreeAgentOptions(recentAgents()), @@ -133,8 +155,86 @@ export function createCommands(deps: CommandDeps): Accessor { ]; }, }, + { + kind: "group", + name: "Queue worktree", + children: (): PaletteItem[] => { + const repos = recentRepos(); + return [ + ...repos.map( + (r): PaletteValueInput => ({ + kind: "value", + name: r.repoName, + description: `Queue worktree in ${r.repoRoot}`, + prefill: () => "", + placeholder: "Intent", + validate: validateIntent, + onSubmit: (intent) => { + deps.queueWorktree(r.repoRoot, intent.trim()); + }, + children: [{ kind: "label", name: "Queue" }], + }), + ), + ...(repos.length === 0 + ? [ + { + kind: "hint" as const, + text: "Repos you cd into will appear here", + }, + ] + : []), + ]; + }, + }, + ...(deps.queuedWorktrees().length > 0 + ? [ + { + kind: "group" as const, + name: "Queued worktrees", + children: (): PaletteItem[] => + deps.queuedWorktrees().map( + (q): PaletteValueInput => ({ + kind: "value", + name: q.intent, + description: `Start queued worktree in ${q.repoPath}`, + prefill: () => q.worktreeName ?? randomName(), + placeholder: "Worktree name", + validate: validateWorktreeName, + onSubmit: (name, selected) => { + const agentCmd = + typeof selected.data === "string" + ? selected.data + : undefined; + deps.startQueuedWorktree(q.id, name.trim(), agentCmd); + }, + children: (): (PaletteLabel | PaletteHint)[] => + worktreeAgentOptions(recentAgents()), + }), + ), + }, + ] + : []), ...(deps.activeId() !== null ? [ + { + kind: "value" as const, + name: deps.activeMeta()?.intent + ? "Edit terminal intent" + : "Set terminal intent", + prefill: () => deps.activeMeta()?.intent ?? "", + placeholder: "Intent", + onSubmit: (intent: string) => deps.handleSetTerminalIntent(intent), + children: [{ kind: "label" as const, name: "Save intent" }], + }, + ...(deps.activeMeta()?.intent + ? [ + { + kind: "action" as const, + name: "Clear terminal intent", + onSelect: () => deps.handleSetTerminalIntent(undefined), + }, + ] + : []), { kind: "action" as const, name: "Close terminal", diff --git a/packages/client/src/right-panel/MetadataInspector.tsx b/packages/client/src/right-panel/MetadataInspector.tsx index 557c20455..5ead4ab44 100644 --- a/packages/client/src/right-panel/MetadataInspector.tsx +++ b/packages/client/src/right-panel/MetadataInspector.tsx @@ -39,6 +39,19 @@ const MetadataInspector: Component<{
+ + {(intent) => ( +
+
+ {intent()} +
+
+ )} +
+ {/* Git */} {(git) => ( diff --git a/packages/client/src/terminal/TerminalMeta.tsx b/packages/client/src/terminal/TerminalMeta.tsx index 56ad3f875..cc76a8e95 100644 --- a/packages/client/src/terminal/TerminalMeta.tsx +++ b/packages/client/src/terminal/TerminalMeta.tsx @@ -52,11 +52,22 @@ const TerminalMeta: Component<{ + + {(intent) => ( + + {intent()} + + )} + {/* Foreground process title — OSC 2 string when present. * Replaces what used to be the cwd slot; cwd is now a * tooltip on the repo name. `flex-1` so it fills until * the progress bar (when shown) eats its right edge. */} - + {(fg) => ( )} + + {(intent) => ( + + {intent()} + + )} + {/* Anchor stops propagation so a tap on the PR doesn't toggle * the enclosing Drawer.Trigger. */} diff --git a/packages/client/src/terminal/useSessionRestore.ts b/packages/client/src/terminal/useSessionRestore.ts index 695f28508..884fcd6eb 100644 --- a/packages/client/src/terminal/useSessionRestore.ts +++ b/packages/client/src/terminal/useSessionRestore.ts @@ -172,6 +172,7 @@ export function useSessionRestore(deps: { for (const t of topLevel) { const newId = await deps.handleCreate(t.cwd, { themeName: t.themeName, + intent: t.intent, canvasLayout: t.canvasLayout, subPanel: t.subPanel, lastActivityAt: t.lastActivityAt, diff --git a/packages/client/src/terminal/useTerminalCrud.ts b/packages/client/src/terminal/useTerminalCrud.ts index 77ba2c2a1..5759b3168 100644 --- a/packages/client/src/terminal/useTerminalCrud.ts +++ b/packages/client/src/terminal/useTerminalCrud.ts @@ -47,6 +47,16 @@ export function useTerminalCrud(deps: { ); } + /** Set or clear a terminal's user-authored intent. */ + function setIntent(id: TerminalId, intent?: string) { + const next = intent?.trim() || undefined; + void client.terminal + .setIntent({ id, intent: next }) + .catch((err: Error) => + toast.error(`Failed to save intent: ${err.message}`), + ); + } + /** Persist a terminal's canvas tile position/size on the server. */ function setCanvasLayout(id: TerminalId, layout: CanvasLayout) { void client.terminal @@ -126,6 +136,7 @@ export function useTerminalCrud(deps: { .create({ cwd, themeName: theme, + intent: initial?.intent, canvasLayout: initial?.canvasLayout, subPanel: initial?.subPanel, lastActivityAt: initial?.lastActivityAt, @@ -211,6 +222,7 @@ export function useTerminalCrud(deps: { return { setThemeName, + setIntent, setCanvasLayout, removeAndAutoSwitch, handleCreate, diff --git a/packages/client/src/terminal/useWorktreeOps.ts b/packages/client/src/terminal/useWorktreeOps.ts index 17e332980..b928468df 100644 --- a/packages/client/src/terminal/useWorktreeOps.ts +++ b/packages/client/src/terminal/useWorktreeOps.ts @@ -1,13 +1,16 @@ /** Worktree operations — create and remove git worktrees with associated terminals. */ -import type { TerminalId } from "kolu-common/surface"; +import type { InitialTerminalMetadata, TerminalId } from "kolu-common/surface"; import { toast } from "solid-sonner"; import { client } from "../wire"; import type { TerminalStore } from "./useTerminalStore"; export function useWorktreeOps(deps: { store: TerminalStore; - handleCreate: (cwd?: string) => Promise; + handleCreate: ( + cwd?: string, + initial?: InitialTerminalMetadata, + ) => Promise; handleKill: (id: TerminalId) => Promise; }) { const { store } = deps; @@ -15,13 +18,19 @@ export function useWorktreeOps(deps: { async function handleCreateWorktree( repoPath: string, name: string, - initialCommand?: string, - ) { + options: { + initialCommand?: string; + initial?: InitialTerminalMetadata; + } = {}, + ): Promise { const id = toast.loading("Creating worktree…"); try { const result = await client.git.worktreeCreate({ repoPath, name }); toast.success(`Created worktree at ${result.path}`, { id }); - const newTerminalId = await deps.handleCreate(result.path); + const newTerminalId = await deps.handleCreate( + result.path, + options.initial, + ); // Recent repos update reactively via trackRecentRepo → publishSystem // Optional initial command (phase 2 of #452): write the agent command @@ -35,13 +44,14 @@ export function useWorktreeOps(deps: { // server-side createTerminal parameter gated on a shell-ready // signal (OSC 133;A prompt mark) — a contract change deliberately // deferred out of phase 2 scope. - if (initialCommand !== undefined) { + if (options.initialCommand !== undefined) { await client.terminal - .sendInput({ id: newTerminalId, data: `${initialCommand}\r` }) + .sendInput({ id: newTerminalId, data: `${options.initialCommand}\r` }) .catch((err: Error) => toast.error(`Failed to start agent: ${err.message}`), ); } + return newTerminalId; } catch (err) { // Toast surfaces the message; don't rethrow — the caller (palette // value-mode onSubmit) is fire-and-forget, and a rethrow leaks as @@ -50,6 +60,7 @@ export function useWorktreeOps(deps: { toast.error(`Failed to create worktree: ${(err as Error).message}`, { id, }); + return undefined; } } diff --git a/packages/client/src/wire.ts b/packages/client/src/wire.ts index 4ee1eec64..43a724ad5 100644 --- a/packages/client/src/wire.ts +++ b/packages/client/src/wire.ts @@ -23,6 +23,7 @@ import { DEFAULT_PREFERENCES, type Preferences, type PreferencesPatch, + type QueuedWorktree, type RecentAgent, type RecentRepo, type SavedSession, @@ -86,6 +87,22 @@ export const recentRepos = (): RecentRepo[] => export const recentAgents = (): RecentAgent[] => _activityFeed.value()?.recentAgents ?? []; +const _queuedWorktrees = app.cells.queuedWorktrees.use({ + authority: "local", + initial: [], + onError: (err) => + toast.error(`Queued worktrees subscription error: ${err.message}`), +}); +export const queuedWorktrees = (): QueuedWorktree[] => + _queuedWorktrees.value() ?? []; +export function setQueuedWorktrees(next: QueuedWorktree[]): void { + void _queuedWorktrees + .set(next) + .catch((err: Error) => + toast.error(`Failed to save queued worktrees: ${err.message}`), + ); +} + const _savedSession = app.cells.session.use({ onError: (err) => toast.error(`Saved-session subscription error: ${err.message}`), diff --git a/packages/common/src/contract.ts b/packages/common/src/contract.ts index 5479b5756..2faaa18e1 100644 --- a/packages/common/src/contract.ts +++ b/packages/common/src/contract.ts @@ -57,6 +57,11 @@ export const TerminalSetThemeInputSchema = z.object({ themeName: z.string(), }); +export const TerminalSetIntentInputSchema = z.object({ + id: TerminalIdSchema, + intent: z.string().optional(), +}); + export const TerminalSetCanvasLayoutInputSchema = z.object({ id: TerminalIdSchema, layout: CanvasLayoutSchema, @@ -118,6 +123,7 @@ export const contract = oc.router({ resize: oc.input(TerminalResizeInputSchema).output(z.void()), sendInput: oc.input(TerminalSendInputSchema).output(z.void()), setTheme: oc.input(TerminalSetThemeInputSchema).output(z.void()), + setIntent: oc.input(TerminalSetIntentInputSchema).output(z.void()), setCanvasLayout: oc .input(TerminalSetCanvasLayoutInputSchema) .output(z.void()), diff --git a/packages/common/src/surface.ts b/packages/common/src/surface.ts index 66f63a40d..21d792578 100644 --- a/packages/common/src/surface.ts +++ b/packages/common/src/surface.ts @@ -130,6 +130,8 @@ export const ServerPersistedTerminalFieldsSchema = z.object({ */ export const ClientPersistedTerminalFieldsSchema = z.object({ themeName: z.string().optional(), + /** User-authored note describing what this live terminal is for. */ + intent: z.string().optional(), /** If set, this terminal is a sub-terminal of the given parent. */ parentId: z.string().optional(), /** Canvas tile position/size — client-reported, used for session restore. */ @@ -207,6 +209,7 @@ export const TerminalMetadataSchema = PersistedTerminalFieldsSchema.merge( * `createMetadata` would reset every restored terminal to `0`. */ export const InitialTerminalMetadataSchema = z.object({ themeName: z.string().optional(), + intent: z.string().optional(), canvasLayout: CanvasLayoutSchema.optional(), subPanel: SubPanelStateSchema.optional(), lastActivityAt: z.number().optional(), @@ -254,6 +257,20 @@ export const ActivityFeedSchema = z.object({ recentAgents: z.array(RecentAgentSchema), }); +// ── Queued worktrees ────────────────────────────────────────────────── + +/** Backlog item for work that is not a live terminal yet. The agent CLI + * is intentionally absent; it is chosen only when the item is started. */ +export const QueuedWorktreeSchema = z.object({ + id: z.string().uuid(), + repoPath: z.string(), + worktreeName: z.string().optional(), + intent: z.string().min(1), + createdAt: z.number(), +}); + +export const QueuedWorktreesSchema = z.array(QueuedWorktreeSchema); + // ── Session persistence ─────────────────────────────────────────────── /** @@ -366,6 +383,7 @@ export type ServerPersistedTerminalFields = z.infer< >; export type RecentRepo = z.infer; export type RecentAgent = z.infer; +export type QueuedWorktree = z.infer; export type SavedTerminal = z.infer; export type ColorScheme = z.infer; export type CodeTabView = z.infer; @@ -475,6 +493,14 @@ export const surface = defineSurface({ verbs: ["get", "test__set"], }, + /** Queued worktrees — durable backlog items that can later become + * live terminal worktrees. Client-owned; persisted by the server. */ + queuedWorktrees: { + schema: QueuedWorktreesSchema, + default: [] as z.infer, + verbs: ["get", "set", "test__set"], + }, + /** Live list of terminals — server-driven on create/kill. Mutations * go through dedicated procedures (`terminal.create`/`kill`/`killAll`) * in the raw oRPC namespace, not via cell.set. */ @@ -540,6 +566,7 @@ export type Surface = SurfaceTypes; export type Preferences = Surface["cells"]["preferences"]["Value"]; export type PreferencesPatch = Surface["cells"]["preferences"]["Patch"]; export type ActivityFeed = Surface["cells"]["activityFeed"]["Value"]; +export type QueuedWorktrees = Surface["cells"]["queuedWorktrees"]["Value"]; export type TerminalMetadata = Surface["collections"]["terminalMetadata"]["Value"]; export type TerminalInfo = z.infer; diff --git a/packages/server/src/router.ts b/packages/server/src/router.ts index f8b8c90f2..e9b60b8a9 100644 --- a/packages/server/src/router.ts +++ b/packages/server/src/router.ts @@ -33,6 +33,7 @@ import { setActiveTerminalId, setCanvasLayout, setSubPanelState, + setTerminalIntent, setTerminalParent, setTerminalTheme, } from "./terminals.ts"; @@ -56,6 +57,7 @@ export const appRouter = t.router({ create: t.terminal.create.handler(async ({ input }) => createTerminal(input.cwd, input.parentId, { themeName: input.themeName, + intent: input.intent, canvasLayout: input.canvasLayout, subPanel: input.subPanel, lastActivityAt: input.lastActivityAt, @@ -76,6 +78,12 @@ export const appRouter = t.router({ setTerminalTheme(input.id, input.themeName); }), + setIntent: t.terminal.setIntent.handler(async ({ input }) => { + requireTerminal(input.id); + log.info({ terminal: input.id }, "set terminal intent"); + setTerminalIntent(input.id, input.intent); + }), + setCanvasLayout: t.terminal.setCanvasLayout.handler(async ({ input }) => { requireTerminal(input.id); setCanvasLayout(input.id, input.layout); diff --git a/packages/server/src/session.test.ts b/packages/server/src/session.test.ts index e5acecc77..378b38181 100644 --- a/packages/server/src/session.test.ts +++ b/packages/server/src/session.test.ts @@ -103,6 +103,26 @@ describe("session persistence", () => { expect(session.terminals[1]?.themeName).toBeUndefined(); }); + it("preserves intent on round-trip", () => { + const terminals: SavedTerminal[] = [ + { + id: "a", + cwd: "/a", + git: null, + intent: "Finish queued worktree implementation", + lastActivityAt: 0, + }, + { id: "b", cwd: "/b", git: null, lastActivityAt: 0 }, + ]; + saveSession({ terminals, activeTerminalId: null }); + const session = getSavedSession(); + assert.ok(session !== null, "session round-trip lost the saved value"); + expect(session.terminals[0]?.intent).toBe( + "Finish queued worktree implementation", + ); + expect(session.terminals[1]?.intent).toBeUndefined(); + }); + it("preserves lastActivityAt on round-trip", () => { // Use real, distinct timestamps so a restore that drops the value // (resetting to 0) cannot pass by coincidence — fixtures of `0` diff --git a/packages/server/src/state.test.ts b/packages/server/src/state.test.ts index 36a781143..57ebe2d5c 100644 --- a/packages/server/src/state.test.ts +++ b/packages/server/src/state.test.ts @@ -86,19 +86,21 @@ describe("migrateLegacyTerminal_1_18_0", () => { }); }); - it("preserves themeName, parentId, canvasLayout, lastAgentCommand", () => { + it("preserves themeName, parentId, canvasLayout, intent, lastAgentCommand", () => { const migrated = migrateLegacyTerminal_1_18_0({ id: "term-4", cwd: "/x", repoName: "x", branch: "main", themeName: "Dracula", + intent: "Fix race in restore flow", parentId: "term-1", canvasLayout: { x: 10, y: 20, w: 300, h: 200 }, lastAgentCommand: "claude --model sonnet", }); expect(migrated).toMatchObject({ themeName: "Dracula", + intent: "Fix race in restore flow", parentId: "term-1", canvasLayout: { x: 10, y: 20, w: 300, h: 200 }, lastAgentCommand: "claude --model sonnet", diff --git a/packages/server/src/state.ts b/packages/server/src/state.ts index 5b3460ac1..145f0d3dc 100644 --- a/packages/server/src/state.ts +++ b/packages/server/src/state.ts @@ -24,6 +24,8 @@ import { DEFAULT_PREFERENCES, type Preferences, PreferencesSchema, + type QueuedWorktrees, + QueuedWorktreesSchema, SavedSessionSchema, } from "kolu-common/surface"; import { z } from "zod"; @@ -87,6 +89,7 @@ export function migrateLegacyTerminal_1_18_0( * this aggregate. Adding a new domain key requires a migration entry below. */ const PersistedStateSchema = z.object({ activityFeed: ActivityFeedSchema, + queuedWorktrees: QueuedWorktreesSchema, session: SavedSessionSchema.nullable(), preferences: PreferencesSchema, }); @@ -98,7 +101,7 @@ type PersistedState = z.infer; * Must be valid semver. `conf` runs all migration handlers * whose keys are > the last-seen version and ≤ this value. */ -const SCHEMA_VERSION = "1.21.0"; +const SCHEMA_VERSION = "1.22.0"; // Callers must pass an explicit directory via KOLU_STATE_DIR. A bare launch // with no env would silently clobber whatever happens to live at conf's @@ -122,6 +125,7 @@ export const store = new Conf({ projectVersion: SCHEMA_VERSION, defaults: { activityFeed: { recentRepos: [], recentAgents: [] } satisfies ActivityFeed, + queuedWorktrees: [] satisfies QueuedWorktrees, session: null, preferences: DEFAULT_PREFERENCES, }, @@ -410,6 +414,13 @@ export const store = new Conf({ })) as typeof session.terminals; store.set("session", { ...session, terminals }); }, + // queuedWorktrees cell added — user-owned backlog items for worktrees + // that have not been started as live terminals yet. + "1.22.0": (store: Conf) => { + if (!store.has("queuedWorktrees")) { + store.set("queuedWorktrees", []); + } + }, }, }); @@ -419,6 +430,7 @@ export const store = new Conf({ // the validated store thereafter. const result = PersistedStateSchema.safeParse({ activityFeed: store.get("activityFeed"), + queuedWorktrees: store.get("queuedWorktrees"), session: store.get("session"), preferences: store.get("preferences"), }); diff --git a/packages/server/src/surface.ts b/packages/server/src/surface.ts index 9fa73e2c9..f235b7df6 100644 --- a/packages/server/src/surface.ts +++ b/packages/server/src/surface.ts @@ -33,6 +33,7 @@ import { match } from "ts-pattern"; import type { ActivityFeed, Preferences, + QueuedWorktrees, SavedSession, TerminalMetadata, } from "kolu-common/surface"; @@ -73,6 +74,8 @@ const activityFeedStore: CellStore = confStore( store, "activityFeed", ); +const queuedWorktreesStore: CellStore = + confStore(store, "queuedWorktrees"); const savedSessionStore: CellStore = confStore(store, "session"); @@ -139,6 +142,7 @@ const { router: surfaceRouterFragment, ctx: surfaceCtxBuilt } = ), }, activityFeed: { store: activityFeedStore }, + queuedWorktrees: { store: queuedWorktreesStore }, session: { // Reads through `getSavedSession` to keep the "empty terminals = null" // legacy normalization at one site (`session.ts` owns that invariant). diff --git a/packages/server/src/terminals.ts b/packages/server/src/terminals.ts index 188cc88c2..2d57d17f8 100644 --- a/packages/server/src/terminals.ts +++ b/packages/server/src/terminals.ts @@ -154,6 +154,7 @@ export function createTerminal( // Seed client-owned initial metadata BEFORE startProviders so the first // `terminalMetadata` collection yield carries these fields (see #642). if (initial?.themeName) meta.themeName = initial.themeName; + if (initial?.intent) meta.intent = initial.intent; if (initial?.canvasLayout) meta.canvasLayout = initial.canvasLayout; if (initial?.subPanel) meta.subPanel = initial.subPanel; if (initial?.lastActivityAt !== undefined) @@ -267,6 +268,17 @@ export function setTerminalTheme(id: TerminalId, themeName: string): void { } } +/** Set or clear the user-authored intent for a terminal. */ +export function setTerminalIntent(id: TerminalId, intent?: string): void { + const entry = getTerminal(id); + if (!entry) return; + const next = intent?.trim(); + updateClientMetadata(entry, id, (m) => { + if (next) m.intent = next; + else delete m.intent; + }); +} + /** Kill and remove all terminals. Used by tests to reset server state between scenarios. */ export function killAllTerminals(): void { // Snapshot entries and clear map BEFORE disposing — prevents onExit diff --git a/packages/surface/README.md b/packages/surface/README.md index 1b4b339a9..fdb5a725a 100644 --- a/packages/surface/README.md +++ b/packages/surface/README.md @@ -347,12 +347,13 @@ Concrete inventory — what every server-pushed reactive surface in Kolu maps to | `terminalListCell` | Live terminal list — drives the workspace switcher, canvas tile set, mobile swipe order | `server` | _server-only_ (via `terminal.create` / `kill` mutations) | `inMemoryStore` (registry is canonical) | | `activityFeedCell` | Recent repos cd'd into + recent agent CLIs spotted via OSC 633;E | `server` | _server-only_ (via `trackRecentRepo` / `trackRecentAgent`) | `confStore("activityFeed")` | | `savedSessionCell` | Last-persisted snapshot of terminals + active id (drives session restore) | `server` | _server-only_ (debounced autosave on `terminals:dirty`) | `confStore("session")` | +| `queuedWorktreesCell` | Durable backlog of repo + intent items that can later become live worktree terminals | `local` (instant UI) | `client.queuedWorktrees.set(next)` | `confStore("queuedWorktrees")` | ### Collections | Descriptor | Backs | Mutation | |---|---|---| -| `terminalMetadataCollection` | Per-terminal metadata (cwd, git, PR, agent state, foreground process, last-activity timestamp for switcher recency) — each terminal's tile chrome and inspector reads its own key | _server-only_ (providers under `meta/*.ts` route writes through `updateServerMetadata` for persisted fields and `updateServerLiveMetadata` for live-only fields — `pr`, `agent`, `foreground` — so the high-frequency agent-stream watcher doesn't fire `terminals:dirty` and trigger no-op session autosaves; the agent provider switches to the persisting variant on each semantic-key transition that bumps `lastActivityAt`) | +| `terminalMetadataCollection` | Per-terminal metadata (cwd, git, PR, agent state, foreground process, terminal intent, last-activity timestamp for switcher recency) — each terminal's tile chrome and inspector reads its own key | _server-internal collection writes_ (providers under `meta/*.ts` route writes through `updateServerMetadata` for persisted fields and `updateServerLiveMetadata` for live-only fields — `pr`, `agent`, `foreground` — so the high-frequency agent-stream watcher doesn't fire `terminals:dirty` and trigger no-op session autosaves; client RPC handlers update client-owned persisted fields like `themeName`, `intent`, layout, and sub-panel state through `updateClientMetadata`) | ### Streams @@ -377,7 +378,7 @@ Shapes that don't fit a descriptor stay as plain oRPC procedures. |---|---|---| | **Bidirectional binary stream** — subscribe-before-yield ordering, custom `onRetry` (xterm buffer reset before re-subscribe's first frame) | `terminal.attach` | `streamCall(client.terminal.attach, { id }, { signal, onRetry })` | | **One-shot queries** — request/response, no subscription dimension | `server.info`, `terminal.screenState`, `terminal.screenText`, `terminal.exportTranscriptHtml` | `await client.X.Y(input)` | -| **Mutations** — request/response writes | `terminal.create` / `kill` / `killAll` / `resize` / `sendInput` / `setTheme` / `setCanvasLayout` / `setSubPanel` / `setActive` / `setParent` / `pasteImage`, `git.worktreeCreate` / `worktreeRemove`, `preferences.update` | `await client.X.Y(input)` (the retry plugin's `retry: 0` default fails them fast) | +| **Mutations** — request/response writes | `terminal.create` / `kill` / `killAll` / `resize` / `sendInput` / `setTheme` / `setIntent` / `setCanvasLayout` / `setSubPanel` / `setActive` / `setParent` / `pasteImage`, `git.worktreeCreate` / `worktreeRemove`, `preferences.update` | `await client.X.Y(input)` (the retry plugin's `retry: 0` default fails them fast) | `streamCall` applies the same `STREAM_RETRY` context the descriptor hooks thread (and merges in an optional `onRetry` callback) so transport drops re-subscribe transparently — escape hatch for non-descriptor shapes, same retry semantics. From 074c4d9a0637f004a694944b7dc191c2f2c23f62 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Sat, 9 May 2026 19:08:13 -0400 Subject: [PATCH 2/8] refactor: centralize queued worktree lifecycle --- packages/client/src/App.tsx | 77 ++++----------- .../canvas/workspace-switcher/SearchPanel.tsx | 7 +- .../src/canvas/workspace-switcher/model.ts | 94 +++++++++++++------ .../client/src/terminal/useQueuedWorktrees.ts | 66 +++++++++++++ .../client/src/terminal/useSessionRestore.ts | 12 +-- .../client/src/terminal/useTerminalCrud.ts | 10 +- packages/common/src/contract.ts | 2 +- packages/common/src/surface.ts | 18 ++-- packages/server/src/router.ts | 8 +- packages/server/src/terminals.ts | 24 +++-- 10 files changed, 191 insertions(+), 127 deletions(-) create mode 100644 packages/client/src/terminal/useQueuedWorktrees.ts diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx index 6a903ea91..200f70d7e 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -8,8 +8,7 @@ import Dialog from "@corvu/dialog"; import { Meta, Title } from "@solidjs/meta"; import type { ServerIdentity } from "kolu-common/contract"; -import type { QueuedWorktree, TerminalId } from "kolu-common/surface"; -import { randomName } from "memorable-names"; +import type { TerminalId } from "kolu-common/surface"; import { type Component, createEffect, @@ -45,12 +44,7 @@ import { useRecorder } from "./recorder/useRecorder"; import WebcamOverlay from "./recorder/WebcamOverlay"; import RightPanelLayout from "./right-panel/RightPanelLayout"; import { useRightPanel } from "./right-panel/useRightPanel"; -import { - client, - queuedWorktrees, - recentAgents, - setQueuedWorktrees, -} from "./wire"; +import { client, recentAgents } from "./wire"; import { serverProcessId, wsStatus } from "./rpc/rpc"; import TransportOverlay from "./rpc/TransportOverlay"; import ShortcutsHelp from "./ShortcutsHelp"; @@ -59,6 +53,7 @@ import { useColorScheme } from "./settings/useColorScheme"; import { useTips } from "./settings/useTips"; import TerminalContent from "./terminal/TerminalContent"; import TerminalMeta from "./terminal/TerminalMeta"; +import { useQueuedWorktrees } from "./terminal/useQueuedWorktrees"; import { useSubPanel } from "./terminal/useSubPanel"; import { useTerminals } from "./terminal/useTerminals"; import ModalDialog, { refocusTerminal } from "./ui/ModalDialog"; @@ -184,40 +179,6 @@ const App: Component = () => { if (id) store.activate(id); } - function queueWorktree(repoPath: string, intent: string) { - const trimmed = intent.trim(); - if (!trimmed) return; - const item: QueuedWorktree = { - id: crypto.randomUUID(), - repoPath, - intent: trimmed, - createdAt: Date.now(), - }; - setQueuedWorktrees([...queuedWorktrees(), item]); - } - - function deleteQueuedWorktree(id: string) { - setQueuedWorktrees(queuedWorktrees().filter((q) => q.id !== id)); - } - - async function startQueuedWorktree( - id: string, - name: string, - initialCommand?: string, - ) { - const item = queuedWorktrees().find((q) => q.id === id); - if (!item) return; - const terminalId = await worktree.handleCreateWorktree( - item.repoPath, - name.trim(), - { - initialCommand, - initial: { intent: item.intent }, - }, - ); - if (terminalId) deleteQueuedWorktree(id); - } - function handleSetTerminalIntent(intent?: string) { const id = store.activeId(); if (!id) return; @@ -229,6 +190,9 @@ const App: Component = () => { crud, isMobile, }); + const queuedWorktrees = useQueuedWorktrees({ + handleCreateWorktree: worktree.handleCreateWorktree, + }); // Shared between the keyboard dispatcher and the command palette so a single // wiring keeps both surfaces in sync. Palette-only deps (theme management, @@ -318,11 +282,14 @@ const App: Component = () => { setDiagnosticInfoOpen, handleCreateWorktree: (repoPath, name, options) => void worktree.handleCreateWorktree(repoPath, name, options), - queuedWorktrees, - queueWorktree, + queuedWorktrees: queuedWorktrees.items, + queueWorktree: queuedWorktrees.enqueue, startQueuedWorktree: (id, name, initialCommand) => - void startQueuedWorktree(id, name, initialCommand), - deleteQueuedWorktree, + void queuedWorktrees.start(id, { + worktreeName: name, + agentCommand: initialCommand, + }), + deleteQueuedWorktree: queuedWorktrees.remove, handleSetTerminalIntent, handleClose: () => { const id = store.activeId(); @@ -523,22 +490,18 @@ const App: Component = () => { workspaceSwitcher={ a.command)} activeId={store.activeId()} getRecency={recencyOf} openRequest={workspaceSwitcherOpenRequest()} onSelect={store.activate} - onStartQueuedWorktree={(id, initialCommand) => { - const item = queuedWorktrees().find((q) => q.id === id); - if (!item) return; - void startQueuedWorktree( - id, - item.worktreeName ?? randomName(), - initialCommand, - ); - }} - onDeleteQueuedWorktree={deleteQueuedWorktree} + onStartQueuedWorktree={(id, initialCommand) => + void queuedWorktrees.start(id, { + agentCommand: initialCommand, + }) + } + onDeleteQueuedWorktree={queuedWorktrees.remove} onCreate={() => openPaletteGroup("New terminal")} /> } diff --git a/packages/client/src/canvas/workspace-switcher/SearchPanel.tsx b/packages/client/src/canvas/workspace-switcher/SearchPanel.tsx index c43e39795..aa1b9320a 100644 --- a/packages/client/src/canvas/workspace-switcher/SearchPanel.tsx +++ b/packages/client/src/canvas/workspace-switcher/SearchPanel.tsx @@ -258,12 +258,7 @@ const WorkspaceSearchPanel: Component<{ )}
- +
-- no workspaces match --
diff --git a/packages/client/src/canvas/workspace-switcher/model.ts b/packages/client/src/canvas/workspace-switcher/model.ts index 42ec52696..4d2e068fd 100644 --- a/packages/client/src/canvas/workspace-switcher/model.ts +++ b/packages/client/src/canvas/workspace-switcher/model.ts @@ -139,6 +139,23 @@ export type WorkspaceSwitcherQueuedWorktree = QueuedWorktree & { searchText: string; }; +/** Unified search/facet item across live terminals and queued worktrees. */ +export type WorkspaceSwitcherItem = + | { + kind: "terminal"; + repoName: string; + color: string; + searchText: string; + terminal: WorkspaceSwitcherEntry; + } + | { + kind: "queued"; + repoName: string; + color: string; + searchText: string; + queued: WorkspaceSwitcherQueuedWorktree; + }; + /** Agent bucket plus the entries currently visible in that column. * `nonStaleCount` is the active subset — entries whose last observed * agent transition is recent enough to count toward the visible badge. @@ -154,9 +171,11 @@ export type WorkspaceSwitcherColumn = export type WorkspaceSwitcherModel = { entries: WorkspaceSwitcherEntry[]; queuedWorktrees: WorkspaceSwitcherQueuedWorktree[]; + items: WorkspaceSwitcherItem[]; compactGroups: WorkspaceSwitcherRepoGroup[]; visibleEntries: WorkspaceSwitcherEntry[]; visibleQueuedWorktrees: WorkspaceSwitcherQueuedWorktree[]; + visibleItems: WorkspaceSwitcherItem[]; selectedRepo: string | null; repoFacets: WorkspaceRepoFacet[]; columns: WorkspaceSwitcherColumn[]; @@ -395,13 +414,38 @@ export function buildWorkspaceSwitcherModel( return { ...base, searchText: queuedSearchTextFor(base) }; }); - const { repoFacets, selectedRepo, visibleEntries, visibleQueuedWorktrees } = - searchResults( - entries, - queuedWorktrees, - options.query ?? "", - options.repoFilter ?? null, - ); + const items: WorkspaceSwitcherItem[] = [ + ...entries.map( + (entry): WorkspaceSwitcherItem => ({ + kind: "terminal", + repoName: entry.repoName, + color: entry.info.repoColor, + searchText: entry.searchText, + terminal: entry, + }), + ), + ...queuedWorktrees.map( + (queued): WorkspaceSwitcherItem => ({ + kind: "queued", + repoName: queued.repoName, + color: "var(--color-accent)", + searchText: queued.searchText, + queued, + }), + ), + ]; + + const { repoFacets, selectedRepo, visibleItems } = searchResults( + items, + options.query ?? "", + options.repoFilter ?? null, + ); + const visibleEntries = visibleItems.flatMap((item) => + item.kind === "terminal" ? [item.terminal] : [], + ); + const visibleQueuedWorktrees = visibleItems.flatMap((item) => + item.kind === "queued" ? [item.queued] : [], + ); const isStale = options.isStale; const columns = WORKSPACE_AGENT_BUCKETS.map((bucket) => { @@ -420,9 +464,11 @@ export function buildWorkspaceSwitcherModel( return { entries, queuedWorktrees, + items, compactGroups: compactGroupsFor(entries, options.activeId ?? null), visibleEntries, visibleQueuedWorktrees, + visibleItems, selectedRepo, repoFacets, columns, @@ -431,30 +477,24 @@ export function buildWorkspaceSwitcherModel( /** Filter, facet, and repo-narrow in one shot. Bundling the three * results makes the dependency explicit: facets count *pre*-repo- - * filter matches (so the user can see how many entries would appear - * in each repo), `visibleEntries` count *post*-filter (only the + * filter matches (so the user can see how many items would appear + * in each repo), `visibleItems` count *post*-filter (only the * selected repo). Splitting them across separate locals invited a * silent reordering bug. */ function searchResults( - entries: WorkspaceSwitcherEntry[], - queuedWorktrees: WorkspaceSwitcherQueuedWorktree[], + items: WorkspaceSwitcherItem[], query: string, repoFilter: string | null, ): { repoFacets: WorkspaceRepoFacet[]; selectedRepo: string | null; - visibleEntries: WorkspaceSwitcherEntry[]; - visibleQueuedWorktrees: WorkspaceSwitcherQueuedWorktree[]; + visibleItems: WorkspaceSwitcherItem[]; } { const tokens = queryTokens(query); const queryMatches = tokens.length === 0 - ? entries - : entries.filter((entry) => matchesQuery(entry, tokens)); - const queuedMatches = - tokens.length === 0 - ? queuedWorktrees - : queuedWorktrees.filter((q) => matchesQuery(q, tokens)); + ? items + : items.filter((item) => matchesQuery(item, tokens)); const facetCounts = new Map(); const addFacet = (repoName: string, color: string) => { @@ -465,11 +505,8 @@ function searchResults( facetCounts.set(repoName, { count: 1, color }); } }; - for (const entry of queryMatches) { - addFacet(entry.repoName, entry.info.repoColor); - } - for (const q of queuedMatches) { - addFacet(q.repoName, "var(--color-accent)"); + for (const item of queryMatches) { + addFacet(item.repoName, item.color); } const repoFacets = [...facetCounts.entries()].map( ([repoName, { count, color }]) => ({ @@ -481,12 +518,9 @@ function searchResults( const selectedRepo = repoFilter && facetCounts.has(repoFilter) ? repoFilter : null; - const visibleEntries = selectedRepo - ? queryMatches.filter((entry) => entry.repoName === selectedRepo) + const visibleItems = selectedRepo + ? queryMatches.filter((item) => item.repoName === selectedRepo) : queryMatches; - const visibleQueuedWorktrees = selectedRepo - ? queuedMatches.filter((q) => q.repoName === selectedRepo) - : queuedMatches; - return { repoFacets, selectedRepo, visibleEntries, visibleQueuedWorktrees }; + return { repoFacets, selectedRepo, visibleItems }; } diff --git a/packages/client/src/terminal/useQueuedWorktrees.ts b/packages/client/src/terminal/useQueuedWorktrees.ts new file mode 100644 index 000000000..fc3ac5b93 --- /dev/null +++ b/packages/client/src/terminal/useQueuedWorktrees.ts @@ -0,0 +1,66 @@ +import type { InitialTerminalMetadata, TerminalId } from "kolu-common/surface"; +import { randomName } from "memorable-names"; +import { queuedWorktrees, setQueuedWorktrees } from "../wire"; + +/** Client-side owner for queued-worktree lifecycle operations. */ +export function useQueuedWorktrees(deps: { + handleCreateWorktree: ( + repoPath: string, + name: string, + options?: { + initialCommand?: string; + initial?: InitialTerminalMetadata; + }, + ) => Promise; +}) { + function enqueue(repoPath: string, intent: string): void { + const trimmed = intent.trim(); + if (!trimmed) return; + setQueuedWorktrees([ + ...queuedWorktrees(), + { + id: crypto.randomUUID(), + repoPath, + intent: trimmed, + createdAt: Date.now(), + }, + ]); + } + + function remove(id: string): void { + setQueuedWorktrees(queuedWorktrees().filter((q) => q.id !== id)); + } + + function rememberWorktreeName(id: string, worktreeName: string): void { + setQueuedWorktrees( + queuedWorktrees().map((q) => (q.id === id ? { ...q, worktreeName } : q)), + ); + } + + async function start( + id: string, + options: { worktreeName?: string; agentCommand?: string } = {}, + ): Promise { + const item = queuedWorktrees().find((q) => q.id === id); + if (!item) return; + const worktreeName = + options.worktreeName?.trim() || item.worktreeName || randomName(); + rememberWorktreeName(id, worktreeName); + const terminalId = await deps.handleCreateWorktree( + item.repoPath, + worktreeName, + { + initialCommand: options.agentCommand, + initial: { intent: item.intent }, + }, + ); + if (terminalId) remove(id); + } + + return { + items: queuedWorktrees, + enqueue, + remove, + start, + }; +} diff --git a/packages/client/src/terminal/useSessionRestore.ts b/packages/client/src/terminal/useSessionRestore.ts index 884fcd6eb..cf1e02266 100644 --- a/packages/client/src/terminal/useSessionRestore.ts +++ b/packages/client/src/terminal/useSessionRestore.ts @@ -8,6 +8,7 @@ import type { TerminalInfo, TerminalMetadata, } from "kolu-common/surface"; +import { initialMetadataFromSavedTerminal } from "kolu-common/surface"; import { createEffect, createSignal } from "solid-js"; import { toast } from "solid-sonner"; import { lifecycle } from "../rpc/rpc"; @@ -170,13 +171,10 @@ export function useSessionRestore(deps: { // so the canvas cascade effect sees the saved layout on its first run // and skips the default-cascade branch (#642). for (const t of topLevel) { - const newId = await deps.handleCreate(t.cwd, { - themeName: t.themeName, - intent: t.intent, - canvasLayout: t.canvasLayout, - subPanel: t.subPanel, - lastActivityAt: t.lastActivityAt, - }); + const newId = await deps.handleCreate( + t.cwd, + initialMetadataFromSavedTerminal(t), + ); oldToNew.set(t.id, newId); // Client-side sub-panel state (activeSubTab, focusTarget) isn't // server-persisted — seed it locally so the restored panel reopens diff --git a/packages/client/src/terminal/useTerminalCrud.ts b/packages/client/src/terminal/useTerminalCrud.ts index 5759b3168..249a50fa4 100644 --- a/packages/client/src/terminal/useTerminalCrud.ts +++ b/packages/client/src/terminal/useTerminalCrud.ts @@ -132,14 +132,14 @@ export function useTerminalCrud(deps: { (peerBgs ? pickTheme(availableThemes, { spread: true, peerBgs }) : undefined); + const nextInitial: InitialTerminalMetadata = { + ...initial, + themeName: theme, + }; const info = await client.terminal .create({ cwd, - themeName: theme, - intent: initial?.intent, - canvasLayout: initial?.canvasLayout, - subPanel: initial?.subPanel, - lastActivityAt: initial?.lastActivityAt, + initial: nextInitial, }) .catch((err: Error) => { toast.error(`Failed to create terminal: ${err.message}`); diff --git a/packages/common/src/contract.ts b/packages/common/src/contract.ts index 2faaa18e1..879f91011 100644 --- a/packages/common/src/contract.ts +++ b/packages/common/src/contract.ts @@ -39,7 +39,7 @@ export const TerminalCreateInputSchema = z cwd: z.string().optional(), parentId: TerminalIdSchema.optional(), }) - .merge(InitialTerminalMetadataSchema); + .extend({ initial: InitialTerminalMetadataSchema.optional() }); export const TerminalResizeInputSchema = z.object({ id: TerminalIdSchema, diff --git a/packages/common/src/surface.ts b/packages/common/src/surface.ts index 21d792578..563183d14 100644 --- a/packages/common/src/surface.ts +++ b/packages/common/src/surface.ts @@ -207,13 +207,10 @@ export const TerminalMetadataSchema = PersistedTerminalFieldsSchema.merge( * value (read from the saved session blob). Threading it through here * keeps recency ordering stable across restart — without it, * `createMetadata` would reset every restored terminal to `0`. */ -export const InitialTerminalMetadataSchema = z.object({ - themeName: z.string().optional(), - intent: z.string().optional(), - canvasLayout: CanvasLayoutSchema.optional(), - subPanel: SubPanelStateSchema.optional(), - lastActivityAt: z.number().optional(), -}); +export const InitialTerminalMetadataSchema = + ClientPersistedTerminalFieldsSchema.omit({ parentId: true }).extend({ + lastActivityAt: z.number().optional(), + }); // ── Terminal cell value + raw-procedure shared schemas ──────────────── @@ -288,6 +285,13 @@ export const SavedTerminalSchema = PersistedTerminalFieldsSchema.extend({ id: z.string(), }); +/** Project a saved terminal snapshot onto the create-time metadata seed. */ +export function initialMetadataFromSavedTerminal( + terminal: z.infer, +): z.infer { + return InitialTerminalMetadataSchema.parse(terminal); +} + export const SavedSessionSchema = z.object({ terminals: z.array(SavedTerminalSchema), /** Which terminal was active at save time. */ diff --git a/packages/server/src/router.ts b/packages/server/src/router.ts index e9b60b8a9..9b04c8645 100644 --- a/packages/server/src/router.ts +++ b/packages/server/src/router.ts @@ -55,13 +55,7 @@ export const appRouter = t.router({ }, terminal: { create: t.terminal.create.handler(async ({ input }) => - createTerminal(input.cwd, input.parentId, { - themeName: input.themeName, - intent: input.intent, - canvasLayout: input.canvasLayout, - subPanel: input.subPanel, - lastActivityAt: input.lastActivityAt, - }), + createTerminal(input.cwd, input.parentId, input.initial), ), resize: t.terminal.resize.handler(async ({ input }) => { diff --git a/packages/server/src/terminals.ts b/packages/server/src/terminals.ts index 2d57d17f8..a7de1531f 100644 --- a/packages/server/src/terminals.ts +++ b/packages/server/src/terminals.ts @@ -16,6 +16,7 @@ import type { SavedTerminal, TerminalId, TerminalInfo, + TerminalMetadata, } from "kolu-common/surface"; import { cleanupClipboardDir } from "./clipboard.ts"; import { log } from "./log.ts"; @@ -88,6 +89,20 @@ function emitListChanged(): void { surfaceCtx.cells.terminalList.set(listTerminals()); } +function applyInitialTerminalMetadata( + meta: TerminalMetadata, + initial?: InitialTerminalMetadata, +): void { + if (!initial) return; + if (initial.themeName !== undefined) meta.themeName = initial.themeName; + if (initial.intent !== undefined) meta.intent = initial.intent; + if (initial.canvasLayout !== undefined) + meta.canvasLayout = initial.canvasLayout; + if (initial.subPanel !== undefined) meta.subPanel = initial.subPanel; + if (initial.lastActivityAt !== undefined) + meta.lastActivityAt = initial.lastActivityAt; +} + /** Create a new terminal, spawn a PTY process. `initial` seeds * client-owned metadata before `startProviders` runs, so the first * `terminalMetadata` collection read carries it — used by session @@ -151,14 +166,9 @@ export function createTerminal( const meta = createMetadata(handle.cwd); if (parentId) meta.parentId = parentId; - // Seed client-owned initial metadata BEFORE startProviders so the first + // Seed initial metadata BEFORE startProviders so the first // `terminalMetadata` collection yield carries these fields (see #642). - if (initial?.themeName) meta.themeName = initial.themeName; - if (initial?.intent) meta.intent = initial.intent; - if (initial?.canvasLayout) meta.canvasLayout = initial.canvasLayout; - if (initial?.subPanel) meta.subPanel = initial.subPanel; - if (initial?.lastActivityAt !== undefined) - meta.lastActivityAt = initial.lastActivityAt; + applyInitialTerminalMetadata(meta, initial); const entry: TerminalProcess = { info: { id, pid: handle.pid }, meta, From 71d8785bad1ec8d450fa6bd4e1f6bd5ab50d88a5 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Sat, 9 May 2026 19:09:24 -0400 Subject: [PATCH 3/8] fix: validate queued worktree names --- packages/common/src/surface.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/common/src/surface.ts b/packages/common/src/surface.ts index 563183d14..5d2b66691 100644 --- a/packages/common/src/surface.ts +++ b/packages/common/src/surface.ts @@ -39,6 +39,7 @@ import { GitInfoSchema, GitStatusInputSchema, GitStatusOutputSchema, + WorktreeNameSchema, } from "kolu-git/schemas"; import { PrResultSchema } from "kolu-github/schemas"; import { OpenCodeInfoSchema } from "kolu-opencode/schemas"; @@ -261,7 +262,7 @@ export const ActivityFeedSchema = z.object({ export const QueuedWorktreeSchema = z.object({ id: z.string().uuid(), repoPath: z.string(), - worktreeName: z.string().optional(), + worktreeName: WorktreeNameSchema.optional(), intent: z.string().min(1), createdAt: z.number(), }); From d2a1d7c6b8205c7b5c2df3e43e492d94251ee216 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Sat, 9 May 2026 19:12:47 -0400 Subject: [PATCH 4/8] test: cover queued worktree intent flow --- .../tests/features/workspace-switcher.feature | 20 +++++++++ .../tests/step_definitions/terminal_steps.ts | 10 +++++ .../workspace_switcher_steps.ts | 43 +++++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/packages/tests/features/workspace-switcher.feature b/packages/tests/features/workspace-switcher.feature index e7c5f7fc1..d9d5acc3f 100644 --- a/packages/tests/features/workspace-switcher.feature +++ b/packages/tests/features/workspace-switcher.feature @@ -64,6 +64,26 @@ Feature: Workspace switcher Then the workspace switcher should show 1 card And there should be no page errors + Scenario: Queued worktree intent is searchable and starts as a live worktree + When I set up a git repo at "/tmp/kolu-queued-wt" + And I run "cd /tmp/kolu-queued-wt" + And I open the command palette + And I select "Queue worktree" in the palette + And I select "kolu-queued-wt" in the palette + And I type "Review payment retry flow" in the palette + And I press Enter + And I click the workspace switcher toggle + Then the workspace switcher panel should be visible + And the workspace switcher should show 1 queued worktree + And a queued worktree should show intent "Review payment retry flow" + When I search the workspace switcher for "payment retry" + Then the workspace switcher should show 1 queued worktree + When I start queued worktree 1 as shell + Then the workspace switcher should show 0 queued worktrees + And the workspace switcher should show a worktree indicator + And the terminal meta should show intent "Review payment retry flow" + And there should be no page errors + Scenario: Workspace switcher shortcut opens search When I press the workspace switcher shortcut Then the workspace switcher panel should be visible diff --git a/packages/tests/step_definitions/terminal_steps.ts b/packages/tests/step_definitions/terminal_steps.ts index b4674a8db..0121553a4 100644 --- a/packages/tests/step_definitions/terminal_steps.ts +++ b/packages/tests/step_definitions/terminal_steps.ts @@ -126,6 +126,16 @@ Then("the terminal canvas should be visible", async function (this: KoluWorld) { await this.canvas.waitFor({ state: "visible" }); }); +Then( + "the terminal meta should show intent {string}", + async function (this: KoluWorld, expected: string) { + const intent = this.page.locator('[data-testid="terminal-meta-intent"]', { + hasText: expected, + }); + await intent.first().waitFor({ state: "visible" }); + }, +); + Then("there should be no page errors", function (this: KoluWorld) { assert.deepStrictEqual(this.errors, []); }); diff --git a/packages/tests/step_definitions/workspace_switcher_steps.ts b/packages/tests/step_definitions/workspace_switcher_steps.ts index 7933ef217..c7f07106e 100644 --- a/packages/tests/step_definitions/workspace_switcher_steps.ts +++ b/packages/tests/step_definitions/workspace_switcher_steps.ts @@ -8,6 +8,8 @@ const PANEL_SELECTOR = '[data-testid="workspace-switcher-panel"]'; const SEARCH_SELECTOR = '[data-testid="workspace-switcher-search"]'; const CARD_SELECTOR = '[data-testid="workspace-switcher-card"]'; const REPO_SELECTOR = '[data-testid="workspace-switcher-repo"]'; +const QUEUED_WORKTREE_SELECTOR = + '[data-testid="workspace-switcher-queued-worktree"]'; Then( "the workspace switcher should be visible", @@ -251,3 +253,44 @@ When( await this.waitForFrame(); }, ); + +Then( + "the workspace switcher should show {int} queued worktree(s)", + async function (this: KoluWorld, expected: number) { + const queued = this.page.locator(QUEUED_WORKTREE_SELECTOR); + if (expected > 0) { + await queued + .nth(expected - 1) + .waitFor({ state: "visible", timeout: POLL_TIMEOUT }); + } + await this.page.waitForFunction( + ({ selector, count }) => + document.querySelectorAll(selector).length === count, + { selector: QUEUED_WORKTREE_SELECTOR, count: expected }, + { timeout: POLL_TIMEOUT }, + ); + }, +); + +Then( + "a queued worktree should show intent {string}", + async function (this: KoluWorld, expected: string) { + const intent = this.page.locator( + '[data-testid="workspace-switcher-queued-intent"]', + { hasText: expected }, + ); + await intent.first().waitFor({ state: "visible", timeout: POLL_TIMEOUT }); + }, +); + +When( + "I start queued worktree {int} as shell", + async function (this: KoluWorld, position: number) { + const queued = this.page + .locator(QUEUED_WORKTREE_SELECTOR) + .nth(position - 1); + await queued.waitFor({ state: "visible", timeout: POLL_TIMEOUT }); + await queued.getByRole("button", { name: "Shell" }).click(); + await this.waitForSettled(); + }, +); From ee3cb2ff031578a59640086e9e6126080df55333 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Sat, 9 May 2026 19:20:57 -0400 Subject: [PATCH 5/8] fix(ci): paginate status summary --- ci/lib.just | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/ci/lib.just b/ci/lib.just index d15ff5308..537d31d3e 100644 --- a/ci/lib.just +++ b/ci/lib.just @@ -158,11 +158,14 @@ _summary: echo "" echo "━━━ CI Summary ({{ sha }}) ━━━" # Fetch all statuses once - all_statuses=$(gh api "repos/{{ repo }}/statuses/{{ sha }}" --jq ' - [.[] | select(.context | startswith("ci/"))] - | group_by(.context) - | map(max_by(.updated_at)) - | .[] | {(.context): .state}' 2>/dev/null | jq -s 'add // {}') + all_statuses=$(gh api --paginate "repos/{{ repo }}/statuses/{{ sha }}" \ + --jq '.[] | select(.context | startswith("ci/"))' 2>/dev/null \ + | jq -s ' + sort_by(.context) + | group_by(.context) + | map(max_by(.updated_at)) + | map({(.context): .state}) + | add // {}') failed=0 while IFS= read -r ctx; do context="ci/$ctx" From d53fec19c71fe97c7d390f67581028bf23aba4a4 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Sat, 9 May 2026 19:27:48 -0400 Subject: [PATCH 6/8] test: stabilize agent mock title signals --- .../tests/step_definitions/codex_steps.ts | 21 ++++++++++--------- .../tests/step_definitions/opencode_steps.ts | 6 ++++-- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/tests/step_definitions/codex_steps.ts b/packages/tests/step_definitions/codex_steps.ts index 9f7cbc3c9..26a1ccdb5 100644 --- a/packages/tests/step_definitions/codex_steps.ts +++ b/packages/tests/step_definitions/codex_steps.ts @@ -30,6 +30,8 @@ import { pollFor } from "../support/poll.ts"; import { type KoluWorld, POLL_TIMEOUT } from "../support/world.ts"; const getCodexDir = () => process.env.KOLU_CODEX_DIR; +const codexTitleBurst = + "sleep 0.2; for i in 1 2 3 4 5 6 7 8; do printf '\\033]0;codex\\007'; sleep 0.25; done"; let mockCwd: string | null = null; let mockFixture: CodexFixture | null = null; @@ -73,17 +75,16 @@ async function startFakeAgent(world: KoluWorld): Promise { // Emitting OSC 2 from inside the body is a stability belt — the // reconcile triggered by bash's preexec OSC 2 fires before the new // process is actually in the foreground (Linux inotify coalescing + - // OSC 7 vs title event ordering under parallel-worker load), so we - // emit a second title event once the fake agent is definitively the - // foreground process. Without this the detection misses in ~5% of - // CI runs. + // OSC 7 vs title event ordering under parallel-worker load), so the + // fake agent emits a short title burst after launch. Without this the + // detection can miss when the single title event arrives too early. // // `terminal/killAll` in hooks.ts:Before tears the pty down between // scenarios, which SIGKILLs the whole tree. const bin = process.env.KOLU_FAKE_CODEX_BIN; if (!bin) throw new Error("KOLU_FAKE_CODEX_BIN must be set"); await world.page.keyboard.type( - `${bin} -c "printf '\\033]0;codex\\007'; sleep 99999 ; :"`, + `${bin} -c "${codexTitleBurst}; sleep 99999 ; :"`, ); await world.page.keyboard.press("Enter"); } @@ -99,14 +100,14 @@ async function startShimmedAgent(world: KoluWorld): Promise { // hook fires on the second line with exactly "codex", so // `parseAgentCommand` normalizes to "codex". // - // The function body emits a second OSC 2 from inside the subshell — + // The function body emits a title burst from inside the subshell — // the reconcile triggered by the preexec OSC 2 fires BEFORE the // subshell is in the foreground (shellIdle=true at that instant - // clears lastAgentCommandName), so a second title event with the - // subshell already running is what lets matchesAgent succeed via - // the preexec-hint branch. + // clears lastAgentCommandName), so later title events with the + // subshell already running let matchesAgent succeed via the + // preexec-hint branch. await world.page.keyboard.type( - `codex() { ( printf '\\033]0;codex\\007'; sleep 99999 ; :); }`, + `codex() { ( ${codexTitleBurst}; sleep 99999 ; :); }`, ); await world.page.keyboard.press("Enter"); await world.page.keyboard.type("codex"); diff --git a/packages/tests/step_definitions/opencode_steps.ts b/packages/tests/step_definitions/opencode_steps.ts index b9fa4621c..2fdb3f320 100644 --- a/packages/tests/step_definitions/opencode_steps.ts +++ b/packages/tests/step_definitions/opencode_steps.ts @@ -25,6 +25,8 @@ import { pollFor } from "../support/poll.ts"; import { type KoluWorld, POLL_TIMEOUT } from "../support/world.ts"; const getOpenCodeDb = () => process.env.KOLU_OPENCODE_DB; +const openCodeTitleBurst = + "sleep 0.2; for i in 1 2 3 4 5 6 7 8; do printf '\\033]0;opencode\\007'; sleep 0.25; done"; let mockCwd: string | null = null; @@ -54,7 +56,7 @@ async function startFakeAgent(world: KoluWorld): Promise { const bin = process.env.KOLU_FAKE_OPENCODE_BIN; if (!bin) throw new Error("KOLU_FAKE_OPENCODE_BIN must be set"); await world.page.keyboard.type( - `${bin} -c "printf '\\033]0;opencode\\007'; sleep 99999 ; :"`, + `${bin} -c "${openCodeTitleBurst}; sleep 99999 ; :"`, ); await world.page.keyboard.press("Enter"); } @@ -62,7 +64,7 @@ async function startFakeAgent(world: KoluWorld): Promise { async function startShimmedAgent(world: KoluWorld): Promise { // See codex_steps.ts::startShimmedAgent for the full rationale. await world.page.keyboard.type( - `opencode() { ( printf '\\033]0;opencode\\007'; sleep 99999 ; :); }`, + `opencode() { ( ${openCodeTitleBurst}; sleep 99999 ; :); }`, ); await world.page.keyboard.press("Enter"); await world.page.keyboard.type("opencode"); From 32a354b1e19eba60e6c5edcec01495923ae37c77 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Sat, 9 May 2026 19:36:13 -0400 Subject: [PATCH 7/8] test: harden Code tab content waits --- .../tests/step_definitions/code_tab_steps.ts | 85 +++++++++++++------ 1 file changed, 58 insertions(+), 27 deletions(-) diff --git a/packages/tests/step_definitions/code_tab_steps.ts b/packages/tests/step_definitions/code_tab_steps.ts index 0997f53ab..b978fcd45 100644 --- a/packages/tests/step_definitions/code_tab_steps.ts +++ b/packages/tests/step_definitions/code_tab_steps.ts @@ -23,6 +23,7 @@ import { type KoluWorld, POLL_TIMEOUT } from "../support/world.ts"; const TREE = '[data-testid="pierre-file-tree"]'; const DIFF_VIEW = '[data-testid="pierre-diff-view"]'; const FILE_VIEW = '[data-testid="pierre-file-view"]'; +const CODE_VIEW_TIMEOUT = 45_000; function fileRow(path: string): string { return `${TREE} [data-item-path="${path}"][data-item-type="file"]:not([data-file-tree-sticky-row])`; @@ -383,6 +384,50 @@ async function waitForViewText( ); } +type SelectedCodeViewSnapshot = { + mounted: boolean; + text: string; + fallbackText: string; +}; + +function readSelectedCodeViewScript(): string { + return `(() => { + const selectors = [ + '[data-testid="pierre-diff-view"]', + '[data-testid="pierre-file-view"]', + ]; + let mounted = false; + let text = ''; + for (const sel of selectors) { + const root = document.querySelector(sel); + if (!root) continue; + mounted = true; + const stack = [root]; + while (stack.length) { + const node = stack.pop(); + if (node.nodeType === 3) text += node.nodeValue || ''; + if (node.nodeType === 1) { + if (node.shadowRoot) for (const ch of node.shadowRoot.childNodes) stack.push(ch); + for (const ch of node.childNodes) stack.push(ch); + } + } + } + return { + mounted, + text, + fallbackText: document.querySelector('[data-testid="diff-content"]')?.textContent || '', + }; + })()`; +} + +async function readSelectedCodeView( + world: KoluWorld, +): Promise { + return (await world.page.evaluate( + readSelectedCodeViewScript(), + )) as SelectedCodeViewSnapshot; +} + Then( "the file content should contain {string}", async function (this: KoluWorld, expected: string) { @@ -649,34 +694,20 @@ Then( * succeeds if the expected text appears in whichever view is mounted. */ Then( "the selected file should show content {string}", + { timeout: 60_000 }, async function (this: KoluWorld, expected: string) { - await this.page.waitForFunction( - (exp) => { - for (const sel of [ - '[data-testid="pierre-diff-view"]', - '[data-testid="pierre-file-view"]', - ]) { - const root = document.querySelector(sel); - if (!root) continue; - const stack: Node[] = [root]; - let text = ""; - while (stack.length) { - const n = stack.pop() as Node; - if (n.nodeType === 3) text += (n as Text).nodeValue || ""; - if (n.nodeType === 1) { - const el = n as Element; - const sh = (el as unknown as { shadowRoot?: ShadowRoot }) - .shadowRoot; - if (sh) for (const ch of sh.childNodes) stack.push(ch); - for (const ch of el.childNodes) stack.push(ch); - } - } - if (text.includes(exp)) return true; - } - return false; + await pollFor({ + observe: () => readSelectedCodeView(this), + isDone: (snapshot) => snapshot.text.includes(expected), + timeoutMs: CODE_VIEW_TIMEOUT, + onTimeout: (last, elapsedMs) => { + const observed = last + ? `mounted=${last.mounted}; view="${last.text.slice(0, 120)}"; fallback="${last.fallbackText.slice(0, 120)}"` + : "no observations"; + return new Error( + `Expected selected file content "${expected}" after ${elapsedMs}ms; ${observed}`, + ); }, - expected, - { timeout: POLL_TIMEOUT }, - ); + }); }, ); From 9e5fd73e3704dd800734b420ea0087b08fe6ee47 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Sun, 10 May 2026 09:04:09 -0400 Subject: [PATCH 8/8] feat: polish queued worktree intent UX --- packages/client/package.json | 3 +- packages/client/src/App.tsx | 31 ++- .../canvas/workspace-switcher/SearchPanel.tsx | 74 ++++-- .../workspace-switcher/WorkspaceSwitcher.tsx | 4 + packages/client/src/clipboard.ts | 52 ++++ packages/client/src/commands.ts | 30 +-- .../client/src/intent/IntentEditorDialog.tsx | 136 ++++++++++ packages/client/src/intent/IntentMarkdown.tsx | 250 ++++++++++++++++++ packages/client/src/intent/IntentSurface.tsx | 124 +++++++++ packages/client/src/intent/text.ts | 9 + packages/client/src/intent/useIntentEditor.ts | 84 ++++++ packages/client/src/terminal/TerminalMeta.tsx | 97 +++++-- packages/client/src/terminal/clipboard.ts | 53 +--- .../client/src/terminal/useQueuedWorktrees.ts | 11 + packages/client/src/ui/Icons.tsx | 34 +++ .../tests/features/workspace-switcher.feature | 54 +++- .../tests/step_definitions/terminal_steps.ts | 32 ++- .../workspace_switcher_steps.ts | 169 +++++++++++- pnpm-lock.yaml | 44 +++ 19 files changed, 1176 insertions(+), 115 deletions(-) create mode 100644 packages/client/src/clipboard.ts create mode 100644 packages/client/src/intent/IntentEditorDialog.tsx create mode 100644 packages/client/src/intent/IntentMarkdown.tsx create mode 100644 packages/client/src/intent/IntentSurface.tsx create mode 100644 packages/client/src/intent/text.ts create mode 100644 packages/client/src/intent/useIntentEditor.ts diff --git a/packages/client/package.json b/packages/client/package.json index edb518e41..ae4fd932e 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -18,9 +18,9 @@ "@kolu/solid-pierre": "workspace:*", "@kolu/surface": "workspace:*", "@orpc/client": "^1.13.13", + "@orpc/contract": "^1.13.13", "@pierre/diffs": "^1.1.20", "@pierre/trees": "^1.0.0-beta.3", - "@orpc/contract": "^1.13.13", "@solid-primitives/event-listener": "^2.4.5", "@solid-primitives/media": "^2.3.5", "@solid-primitives/resize-observer": "^2.1.5", @@ -37,6 +37,7 @@ "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", "fix-webm-duration": "^1.0.6", + "marked": "^18.0.2", "memorable-names": "workspace:*", "neverthrow": "^8.2.0", "nonempty": "workspace:*", diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx index 200f70d7e..704daa648 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -52,6 +52,8 @@ import { screenshotTerminal } from "./screenshotTerminal"; import { useColorScheme } from "./settings/useColorScheme"; import { useTips } from "./settings/useTips"; import TerminalContent from "./terminal/TerminalContent"; +import IntentEditorDialog from "./intent/IntentEditorDialog"; +import { useIntentEditor } from "./intent/useIntentEditor"; import TerminalMeta from "./terminal/TerminalMeta"; import { useQueuedWorktrees } from "./terminal/useQueuedWorktrees"; import { useSubPanel } from "./terminal/useSubPanel"; @@ -194,6 +196,15 @@ const App: Component = () => { handleCreateWorktree: worktree.handleCreateWorktree, }); + const intentEditor = useIntentEditor({ + queuedWorktrees: queuedWorktrees.items, + getTerminalIntent: (id) => store.getMetadata(id)?.intent, + setTerminalIntent: crud.setIntent, + enqueueQueuedWorktree: queuedWorktrees.enqueue, + updateQueuedWorktreeIntent: queuedWorktrees.updateIntent, + onClose: () => requestAnimationFrame(refocusTerminal), + }); + // Shared between the keyboard dispatcher and the command palette so a single // wiring keeps both surfaces in sync. Palette-only deps (theme management, // dialog setters, debug, etc.) are added below in the createCommands call. @@ -283,13 +294,15 @@ const App: Component = () => { handleCreateWorktree: (repoPath, name, options) => void worktree.handleCreateWorktree(repoPath, name, options), queuedWorktrees: queuedWorktrees.items, - queueWorktree: queuedWorktrees.enqueue, + openQueueWorktreeIntent: intentEditor.openNewQueued, startQueuedWorktree: (id, name, initialCommand) => void queuedWorktrees.start(id, { worktreeName: name, agentCommand: initialCommand, }), deleteQueuedWorktree: queuedWorktrees.remove, + openActiveTerminalIntent: () => + intentEditor.openActiveTerminal(store.activeId()), handleSetTerminalIntent, handleClose: () => { const id = store.activeId(); @@ -416,6 +429,15 @@ const App: Component = () => { onOpenChange={setDiagnosticInfoOpen} activeId={store.activeId()} /> + { }) } onDeleteQueuedWorktree={queuedWorktrees.remove} + onEditQueuedWorktree={intentEditor.openQueued} + onEditTerminalIntent={intentEditor.openTerminal} onCreate={() => openPaletteGroup("New terminal")} /> } @@ -571,7 +595,10 @@ const App: Component = () => { onSelect={store.setActiveSilently} onClose={(id) => closeTerminal(id)} renderTileTitle={(id) => ( - + intentEditor.openTerminal(id)} + /> )} renderTileTitleActions={(id) => ( void; onDeleteQueuedWorktree: (id: string) => void; + onEditQueuedWorktree: (id: string) => void; + onEditTerminalIntent: (id: TerminalId) => void; onClose: () => void; }> = (props) => { const store = useTerminalStore(); @@ -199,6 +202,7 @@ const WorkspaceSearchPanel: Component<{ onDelete={() => props.onDeleteQueuedWorktree(queued().id) } + onEdit={() => props.onEditQueuedWorktree(queued().id)} /> )} @@ -249,6 +253,9 @@ const WorkspaceSearchPanel: Component<{ tileBg={tileTheme(entry().id).bg} tileFg={tileTheme(entry().id).fg} onSelect={() => props.onSelect(entry().id)} + onEditIntent={() => + props.onEditTerminalIntent(entry().id) + } /> )} @@ -269,11 +276,13 @@ const WorkspaceSearchPanel: Component<{ ); }; +/** Queued-worktree card with intent preview and start/delete actions. */ const QueuedWorktreeCard: Component<{ queued: WorkspaceSwitcherQueuedWorktree; recentAgentCommands: string[]; onStart: (agentCommand?: string) => void; onDelete: () => void; + onEdit: () => void; }> = (props) => { const agentCommands = () => props.recentAgentCommands.slice(0, 3); return ( @@ -281,9 +290,13 @@ const QueuedWorktreeCard: Component<{ data-testid="workspace-switcher-queued-worktree" data-queued-worktree-id={props.queued.id} data-repo-name={props.queued.repoName} - class="relative rounded-lg border border-accent/35 bg-surface-0/55 p-2.5 text-left" + class="relative rounded-lg border border-accent/35 bg-surface-0/55 px-2.5 pb-2.5 pt-5 text-left" title={props.queued.intent} > +
{props.queued.repoName} @@ -298,15 +311,16 @@ const QueuedWorktreeCard: Component<{
-
- {props.queued.intent} -
{props.queued.worktreeName ?? "name chosen on start"}
+
- - {(intent) => ( -
- {intent()} -
+ + {(value) => ( + )} @@ -553,7 +587,7 @@ const WorkspaceCard: Component<{ )}
- + ); }; diff --git a/packages/client/src/canvas/workspace-switcher/WorkspaceSwitcher.tsx b/packages/client/src/canvas/workspace-switcher/WorkspaceSwitcher.tsx index 5a33d2a1e..fd190ba91 100644 --- a/packages/client/src/canvas/workspace-switcher/WorkspaceSwitcher.tsx +++ b/packages/client/src/canvas/workspace-switcher/WorkspaceSwitcher.tsx @@ -55,6 +55,8 @@ const WorkspaceSwitcher: Component<{ onSelect: (id: TerminalId) => void; onStartQueuedWorktree: (id: string, agentCommand?: string) => void; onDeleteQueuedWorktree: (id: string) => void; + onEditQueuedWorktree: (id: string) => void; + onEditTerminalIntent: (id: TerminalId) => void; /** Open the "new terminal" flow. */ onCreate: () => void; }> = (props) => { @@ -229,6 +231,8 @@ const WorkspaceSwitcher: Component<{ recentAgentCommands={props.recentAgentCommands} onStartQueuedWorktree={startQueuedAndClose} onDeleteQueuedWorktree={props.onDeleteQueuedWorktree} + onEditQueuedWorktree={props.onEditQueuedWorktree} + onEditTerminalIntent={props.onEditTerminalIntent} onClose={closePanel} /> diff --git a/packages/client/src/clipboard.ts b/packages/client/src/clipboard.ts new file mode 100644 index 000000000..27172e908 --- /dev/null +++ b/packages/client/src/clipboard.ts @@ -0,0 +1,52 @@ +/** + * Clipboard write with a fallback for non-secure contexts. + * + * `navigator.clipboard` is only defined in secure contexts (https, localhost, + * 127.0.0.1). On plain http to any other host it is undefined, so direct + * writes can throw `TypeError: Cannot read properties of undefined`. + * + * The fallback selects a hidden textarea and runs `document.execCommand("copy")`, + * which works in any browsing context at the cost of a brief focus steal. + */ + +import { toast } from "solid-sonner"; + +/** Write `text` to the system clipboard, falling back to execCommand when + * navigator.clipboard is unavailable or throws. Throws if both paths fail. */ +export async function writeTextToClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + // Fall through to execCommand — navigator.clipboard can reject for + // reasons other than missing secure context (permission denied, etc.). + } + } + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + try { + textarea.select(); + const ok = document.execCommand("copy"); + if (!ok) throw new Error("clipboard access blocked"); + } finally { + document.body.removeChild(textarea); + } +} + +/** Copy text and show a success/failure toast. */ +export async function copyTextWithToast( + text: string, + messages: { success: string; failure: string }, +): Promise { + try { + await writeTextToClipboard(text); + toast.success(messages.success); + } catch (err) { + console.error(`${messages.failure}:`, err); + toast.error(`${messages.failure}: ${(err as Error).message}`); + } +} diff --git a/packages/client/src/commands.ts b/packages/client/src/commands.ts index 1aa307ce2..67185e6b0 100644 --- a/packages/client/src/commands.ts +++ b/packages/client/src/commands.ts @@ -19,6 +19,7 @@ import type { PaletteValueInput, } from "./CommandPalette"; import { type ActionContext, actionPaletteCommand } from "./input/actions"; +import { firstIntentLine } from "./intent/text"; import { client } from "./wire"; import { recentAgents, recentRepos } from "./wire"; @@ -31,10 +32,6 @@ function validateWorktreeName(name: string): string | null { return result.error.issues[0]?.message ?? "Invalid worktree name"; } -function validateIntent(intent: string): string | null { - return intent.trim().length > 0 ? null : "Intent is required"; -} - /** PaletteItems listing each recent agent command. Used by the Debug → * "Recent agents" entry (phase 1 prefill flow). */ function agentItems( @@ -98,13 +95,14 @@ export interface CommandDeps extends ActionContext { }, ) => void; queuedWorktrees: Accessor; - queueWorktree: (repoPath: string, intent: string) => void; + openQueueWorktreeIntent: (repoPath: string, repoName: string) => void; startQueuedWorktree: ( id: string, name: string, initialCommand?: string, ) => void; deleteQueuedWorktree: (id: string) => void; + openActiveTerminalIntent: () => void; handleSetTerminalIntent: (intent?: string) => void; handleClose: () => void; // Debug @@ -162,17 +160,12 @@ export function createCommands(deps: CommandDeps): Accessor { const repos = recentRepos(); return [ ...repos.map( - (r): PaletteValueInput => ({ - kind: "value", + (r): PaletteAction => ({ + kind: "action", name: r.repoName, description: `Queue worktree in ${r.repoRoot}`, - prefill: () => "", - placeholder: "Intent", - validate: validateIntent, - onSubmit: (intent) => { - deps.queueWorktree(r.repoRoot, intent.trim()); - }, - children: [{ kind: "label", name: "Queue" }], + onSelect: () => + deps.openQueueWorktreeIntent(r.repoRoot, r.repoName), }), ), ...(repos.length === 0 @@ -195,7 +188,7 @@ export function createCommands(deps: CommandDeps): Accessor { deps.queuedWorktrees().map( (q): PaletteValueInput => ({ kind: "value", - name: q.intent, + name: firstIntentLine(q.intent), description: `Start queued worktree in ${q.repoPath}`, prefill: () => q.worktreeName ?? randomName(), placeholder: "Worktree name", @@ -217,14 +210,11 @@ export function createCommands(deps: CommandDeps): Accessor { ...(deps.activeId() !== null ? [ { - kind: "value" as const, + kind: "action" as const, name: deps.activeMeta()?.intent ? "Edit terminal intent" : "Set terminal intent", - prefill: () => deps.activeMeta()?.intent ?? "", - placeholder: "Intent", - onSubmit: (intent: string) => deps.handleSetTerminalIntent(intent), - children: [{ kind: "label" as const, name: "Save intent" }], + onSelect: deps.openActiveTerminalIntent, }, ...(deps.activeMeta()?.intent ? [ diff --git a/packages/client/src/intent/IntentEditorDialog.tsx b/packages/client/src/intent/IntentEditorDialog.tsx new file mode 100644 index 000000000..a647694a1 --- /dev/null +++ b/packages/client/src/intent/IntentEditorDialog.tsx @@ -0,0 +1,136 @@ +import Dialog from "@corvu/dialog"; +import { type Component, createEffect, createSignal, on, Show } from "solid-js"; +import { toast } from "solid-sonner"; +import { copyTextWithToast } from "../clipboard"; +import { CloseIcon, CopyIcon } from "../ui/Icons"; +import ModalDialog from "../ui/ModalDialog"; +import { IntentMarkdownBlock } from "./IntentMarkdown"; + +/** Modal editor for terminal and queued-worktree intent text. */ +const IntentEditorDialog: Component<{ + open: boolean; + title: string; + value: string; + allowClear?: boolean; + onOpenChange: (open: boolean) => void; + onSave: (intent: string) => void; + onClear?: () => void; +}> = (props) => { + let textareaRef: HTMLTextAreaElement | undefined; + const [draft, setDraft] = createSignal(""); + const trimmed = () => draft().trim(); + const canSave = () => trimmed().length > 0; + + createEffect( + on( + () => props.open, + (open) => { + if (!open) return; + setDraft(props.value); + queueMicrotask(() => { + textareaRef?.focus(); + textareaRef?.select(); + }); + }, + ), + ); + + const save = () => { + const next = trimmed(); + if (!next) { + toast.error("Intent is required"); + return; + } + props.onSave(next); + props.onOpenChange(false); + }; + + const clear = () => { + props.onClear?.(); + props.onOpenChange(false); + }; + + const copy = () => { + const value = trimmed(); + if (!value) return; + void copyTextWithToast(value, { + success: "Copied intent to clipboard", + failure: "Failed to copy intent", + }); + }; + + return ( + + +
+ + {props.title} + +
+