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/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" 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 4e88ccb7e..704daa648 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -44,7 +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 } from "./wire"; +import { client, recentAgents } from "./wire"; import { serverProcessId, wsStatus } from "./rpc/rpc"; import TransportOverlay from "./rpc/TransportOverlay"; import ShortcutsHelp from "./ShortcutsHelp"; @@ -52,7 +52,10 @@ 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"; import { useTerminals } from "./terminal/useTerminals"; import ModalDialog, { refocusTerminal } from "./ui/ModalDialog"; @@ -178,11 +181,29 @@ const App: Component = () => { if (id) store.activate(id); } + function handleSetTerminalIntent(intent?: string) { + const id = store.activeId(); + if (!id) return; + crud.setIntent(id, intent); + } + const arrange = useCanvasArrange({ store, crud, isMobile, }); + const queuedWorktrees = useQueuedWorktrees({ + 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, @@ -270,8 +291,19 @@ 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: queuedWorktrees.items, + 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(); if (id) closeTerminal(id); @@ -397,6 +429,15 @@ const App: Component = () => { onOpenChange={setDiagnosticInfoOpen} activeId={store.activeId()} /> + { workspaceSwitcher={ a.command)} activeId={store.activeId()} getRecency={recencyOf} openRequest={workspaceSwitcherOpenRequest()} onSelect={store.activate} + onStartQueuedWorktree={(id, initialCommand) => + void queuedWorktrees.start(id, { + agentCommand: initialCommand, + }) + } + onDeleteQueuedWorktree={queuedWorktrees.remove} + onEditQueuedWorktree={intentEditor.openQueued} + onEditTerminalIntent={intentEditor.openTerminal} onCreate={() => openPaletteGroup("New terminal")} /> } @@ -544,7 +595,10 @@ const App: Component = () => { onSelect={store.setActiveSilently} onClose={(id) => closeTerminal(id)} renderTileTitle={(id) => ( - + intentEditor.openTerminal(id)} + /> )} renderTileTitleActions={(id) => ( └─ - - {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; + onEditQueuedWorktree: (id: string) => void; + onEditTerminalIntent: (id: TerminalId) => 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 +95,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 +161,55 @@ 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) + } + onEdit={() => props.onEditQueuedWorktree(queued().id)} + /> + )} + +
+
+
+
{(column) => (
props.onSelect(entry().id)} + onEditIntent={() => + props.onEditTerminalIntent(entry().id) + } /> )} @@ -201,9 +265,9 @@ const WorkspaceSearchPanel: Component<{ )}
- +
- ── no live terminals match ── + -- no workspaces match --
@@ -212,6 +276,79 @@ 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 ( +
+ +
+ + {props.queued.repoName} + + +
+
+ {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<{ @@ -268,16 +405,25 @@ const WorkspaceCard: Component<{ tileBg: string; tileFg: string; onSelect: () => void; + onEditIntent: () => void; }> = (props) => { const agent = () => props.entry.info.meta.agent; const pr = () => prSummary(props.entry); const tokens = () => tokenLine(agent()); const bucketInfo = () => bucketDescriptor(agentBucket(agent())); const lastActive = () => formatTimeAgo(props.entry.info.meta.lastActivityAt); + const intent = () => props.entry.info.meta.intent; + const selectOnKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + props.onSelect(); + }; return ( - + ); }; diff --git a/packages/client/src/canvas/workspace-switcher/WorkspaceSwitcher.tsx b/packages/client/src/canvas/workspace-switcher/WorkspaceSwitcher.tsx index 2175242cf..fd190ba91 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,10 @@ 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; + onEditQueuedWorktree: (id: string) => void; + onEditTerminalIntent: (id: TerminalId) => void; /** Open the "new terminal" flow. */ onCreate: () => void; }> = (props) => { @@ -70,6 +76,7 @@ const WorkspaceSwitcher: Component<{ activeId: props.activeId, getRecency: props.getRecency, isStale, + queuedWorktrees: props.queuedWorktrees, }), ); @@ -145,6 +152,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} + onEditQueuedWorktree={props.onEditQueuedWorktree} + onEditTerminalIntent={props.onEditTerminalIntent} 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..4d2e068fd 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,29 @@ export type WorkspaceRepoFacet = { color: string; }; +/** Queued worktree projection rendered separately from live terminals. */ +export type WorkspaceSwitcherQueuedWorktree = QueuedWorktree & { + repoName: string; + 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. @@ -145,8 +170,12 @@ export type WorkspaceSwitcherColumn = /** Complete derived model for collapsed and expanded switcher renderers. */ export type WorkspaceSwitcherModel = { entries: WorkspaceSwitcherEntry[]; + queuedWorktrees: WorkspaceSwitcherQueuedWorktree[]; + items: WorkspaceSwitcherItem[]; compactGroups: WorkspaceSwitcherRepoGroup[]; visibleEntries: WorkspaceSwitcherEntry[]; + visibleQueuedWorktrees: WorkspaceSwitcherQueuedWorktree[]; + visibleItems: WorkspaceSwitcherItem[]; selectedRepo: string | null; repoFacets: WorkspaceRepoFacet[]; columns: WorkspaceSwitcherColumn[]; @@ -241,6 +270,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 +296,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 +387,7 @@ export function buildWorkspaceSwitcherModel( activeId?: TerminalId | null; getRecency?: (id: TerminalId) => number; isStale?: (lastActivityAt: number) => boolean; + queuedWorktrees?: QueuedWorktree[]; } = {}, ): WorkspaceSwitcherModel { const ordered = options.getRecency @@ -370,12 +407,45 @@ 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, + 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) => { @@ -393,8 +463,12 @@ export function buildWorkspaceSwitcherModel( return { entries, + queuedWorktrees, + items, compactGroups: compactGroupsFor(entries, options.activeId ?? null), visibleEntries, + visibleQueuedWorktrees, + visibleItems, selectedRepo, repoFacets, columns, @@ -403,36 +477,36 @@ 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[], + items: WorkspaceSwitcherItem[], query: string, repoFilter: string | null, ): { repoFacets: WorkspaceRepoFacet[]; selectedRepo: string | null; - visibleEntries: WorkspaceSwitcherEntry[]; + visibleItems: WorkspaceSwitcherItem[]; } { const tokens = queryTokens(query); const queryMatches = tokens.length === 0 - ? entries - : entries.filter((entry) => matchesQuery(entry, tokens)); + ? items + : items.filter((item) => matchesQuery(item, 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 item of queryMatches) { + addFacet(item.repoName, item.color); } const repoFacets = [...facetCounts.entries()].map( ([repoName, { count, color }]) => ({ @@ -444,9 +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; - return { repoFacets, selectedRepo, visibleEntries }; + return { repoFacets, selectedRepo, visibleItems }; } 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 d3a35fa6b..67185e6b0 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"; @@ -15,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"; @@ -84,8 +89,21 @@ export interface CommandDeps extends ActionContext { handleCreateWorktree: ( repoPath: string, name: string, + options?: { + initialCommand?: string; + initial?: InitialTerminalMetadata; + }, + ) => void; + queuedWorktrees: Accessor; + 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 simulateAlert: () => void; @@ -116,7 +134,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 +153,78 @@ export function createCommands(deps: CommandDeps): Accessor { ]; }, }, + { + kind: "group", + name: "Queue worktree", + children: (): PaletteItem[] => { + const repos = recentRepos(); + return [ + ...repos.map( + (r): PaletteAction => ({ + kind: "action", + name: r.repoName, + description: `Queue worktree in ${r.repoRoot}`, + onSelect: () => + deps.openQueueWorktreeIntent(r.repoRoot, r.repoName), + }), + ), + ...(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: firstIntentLine(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: "action" as const, + name: deps.activeMeta()?.intent + ? "Edit terminal intent" + : "Set terminal intent", + onSelect: deps.openActiveTerminalIntent, + }, + ...(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/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} + +
+