From 7a7f9a5b3d1af1bab4f7d50484b836c5dceb7a50 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 25 Jun 2026 16:40:27 -0500 Subject: [PATCH] feat(desktop): add composer coding rail and worktree flow --- apps/desktop/src/app/chat/composer/focus.ts | 25 +- apps/desktop/src/app/chat/composer/index.tsx | 137 ++++- .../chat/composer/status-stack/coding-row.tsx | 473 ++++++++++++++++++ .../app/chat/composer/status-stack/index.tsx | 86 ++-- .../composer/status-stack/preview-row.tsx | 78 ++- .../chat/composer/status-stack/status-row.tsx | 7 +- apps/desktop/src/app/command-center/index.tsx | 11 +- .../desktop/src/app/command-palette/index.tsx | 31 +- apps/desktop/src/app/hooks/use-keybinds.ts | 7 + 9 files changed, 765 insertions(+), 90 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts index 3de3f5c9800..d3969b70020 100644 --- a/apps/desktop/src/app/chat/composer/focus.ts +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -10,8 +10,8 @@ * steal focus from the composer effect. */ -import { RICH_INPUT_SLOT } from './rich-editor' import type { InlineRefInput } from './inline-refs' +import { RICH_INPUT_SLOT } from './rich-editor' export type ComposerTarget = 'edit' | 'main' export type ComposerInsertMode = 'block' | 'inline' @@ -34,8 +34,14 @@ interface InsertRefsDetail { const FOCUS_EVENT = 'hermes:composer-focus' const INSERT_EVENT = 'hermes:composer-insert' const INSERT_REFS_EVENT = 'hermes:composer-insert-refs' +const SUBMIT_EVENT = 'hermes:composer-submit' const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle' +interface SubmitDetail { + target: ComposerTarget + text: string +} + let activeTarget: ComposerTarget = 'main' const resolve = (target: ComposerTarget | 'active') => (target === 'active' ? activeTarget : target) @@ -106,6 +112,23 @@ export const requestComposerInsertRefs = ( export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail) => void) => subscribe(INSERT_REFS_EVENT, handler) +/** Submit a prompt through a composer as if the user typed + sent it. Lets + * external panels (e.g. the review pane's "let the agent ship it" button) hand + * the agent a task without the user round-tripping through the input. */ +export const requestComposerSubmit = ( + text: string, + { target = 'active' }: { target?: ComposerTarget | 'active' } = {} +) => { + const trimmed = text.trim() + + if (trimmed) { + dispatch(SUBMIT_EVENT, { target: resolve(target), text: trimmed }) + } +} + +export const onComposerSubmitRequest = (handler: (detail: SubmitDetail) => void) => + subscribe(SUBMIT_EVENT, handler) + /** Toggle the active composer's voice conversation — the `composer.voice` * hotkey (Ctrl+B) reaching into the composer that owns the voice state. */ export const requestVoiceToggle = () => dispatch<{ at: number }>(VOICE_TOGGLE_EVENT, { at: Date.now() }) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index d4ec0a36a1d..66e5efe9555 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -45,8 +45,8 @@ import { $composerPoppedOut, POPOUT_WIDTH_REM, readPopoutBounds, - setComposerPoppedOut, - setComposerPopoutPosition + setComposerPopoutPosition, + setComposerPoppedOut } from '@/store/composer-popout' import { $queuedPromptsBySession, @@ -60,8 +60,10 @@ import { updateQueuedPrompt } from '@/store/composer-queue' import { $statusItemsBySession } from '@/store/composer-status' -import { $previewStatusBySession } from '@/store/preview-status' import { notify } from '@/store/notifications' +import { $previewStatusBySession } from '@/store/preview-status' +import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects' +import { toggleReview } from '@/store/review' import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session' import { $threadScrolledUp } from '@/store/thread-scroll' import { isSecondaryWindow } from '@/store/windows' @@ -80,6 +82,7 @@ import { onComposerFocusRequest, onComposerInsertRefsRequest, onComposerInsertRequest, + onComposerSubmitRequest, onComposerVoiceToggleRequest } from './focus' import { HelpHint } from './help-hint' @@ -108,6 +111,7 @@ import { slashChipElement } from './rich-editor' import { ComposerStatusStack } from './status-stack' +import { CodingStatusRow } from './status-stack/coding-row' import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils' import { ComposerTriggerPopover } from './trigger-popover' import type { ChatBarProps } from './types' @@ -1351,6 +1355,72 @@ export function ChatBar({ } }, [setComposerText]) + // Hand a worktree off to the controller: open a fresh session anchored there, + // carrying the composer draft as its first turn. Clearing here means the draft + // travels to the new session instead of getting stashed under this one. + const openInWorktree = useCallback( + (path: string) => { + const text = draftRef.current + clearDraft() + clearComposerAttachments() + requestStartWorkSession(path, text) + }, + [clearDraft] + ) + + // Branch off into a NEW worktree (base = branch name, or current HEAD). A + // create failure throws back to the row (which toasts) before we touch the + // draft; a missing cwd / remote backend no-ops (the row hides the affordance). + const handleBranchOff = useCallback( + async (branch: string, base?: string) => { + const repoPath = cwd?.trim() + const result = repoPath && (await startWorkInRepo(repoPath, { base, branch, name: branch })) + + if (result) { + openInWorktree(result.path) + } + }, + [cwd, openInWorktree] + ) + + // Convert an EXISTING branch into a fresh worktree + session (no new branch). + // Mirrors handleBranchOff's hand-off: create the worktree, then open a session + // anchored there carrying the draft. + const handleConvertBranch = useCallback( + async (branch: string, path?: null | string) => { + if (path?.trim()) { + openInWorktree(path) + + return + } + + const repoPath = cwd?.trim() + const result = repoPath && (await startWorkInRepo(repoPath, { existingBranch: branch })) + + if (result) { + openInWorktree(result.path) + } + }, + [cwd, openInWorktree] + ) + + const handleListBranches = useCallback(async () => { + const repoPath = cwd?.trim() + + return repoPath ? listRepoBranches(repoPath) : [] + }, [cwd]) + + const handleSwitchBranch = useCallback( + async (branch: string) => { + const repoPath = cwd?.trim() + + if (repoPath) { + await switchBranchInRepo(repoPath, branch) + } + }, + [cwd] + ) + const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => { draftRef.current = text setComposerText(text) @@ -1674,6 +1744,41 @@ export function ChatBar({ } }, [autoDrainNext, busy, queuedPrompts.length]) + // Esc cancels the in-flight turn when the CHAT has focus — not just the + // composer input (which has its own handler above). Clicking into the + // transcript and hitting Esc now stops the run, matching the Stop button. + // Intentional only: we bail if (a) the composer/another field already + // handled Esc (defaultPrevented), (b) focus is in any input/textarea/ + // contenteditable (you're typing, not stopping), or (c) a dialog/popover is + // open — Esc must close that overlay, never double as canceling the stream + // behind it. A latest-handler ref keeps the listener registered once. + const escCancelRef = useRef<(event: globalThis.KeyboardEvent) => void>(() => {}) + escCancelRef.current = (event: globalThis.KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented || !busy) { + return + } + + const active = document.activeElement as HTMLElement | null + if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) { + return + } + + if (document.querySelector('[role="dialog"],[role="alertdialog"],[data-radix-popper-content-wrapper]')) { + return + } + + event.preventDefault() + triggerHaptic('cancel') + void Promise.resolve(onCancel()) + } + + useEffect(() => { + const onKeyDown = (event: globalThis.KeyboardEvent) => escCancelRef.current(event) + window.addEventListener('keydown', onKeyDown) + + return () => window.removeEventListener('keydown', onKeyDown) + }, []) + // Queue-edit cleanup: on session swap the scope effect already stashed the // edit snapshot; only restore into the composer when still on the same scope. useEffect(() => { @@ -1706,6 +1811,22 @@ export function ChatBar({ .catch(restore) } + // External "submit this prompt" requests (e.g. the review pane's agent-ship + // button) route through the same send path. A ref keeps the listener stable + // while always calling the latest dispatchSubmit closure. + const dispatchSubmitRef = useRef(dispatchSubmit) + dispatchSubmitRef.current = dispatchSubmit + + useEffect( + () => + onComposerSubmitRequest(({ target, text }) => { + if (target === 'main' && !inputDisabled) { + dispatchSubmitRef.current(text) + } + }), + [inputDisabled] + ) + const submitDraft = () => { if (disabled) { return @@ -2099,7 +2220,7 @@ export function ChatBar({
+
Promise + /** Check an existing branch out into a fresh worktree + session (no new + * branch). Drives the dialog's "convert a branch" picker. */ + onConvertBranch?: (branch: string, path?: null | string) => Promise + /** List the repo's local branches for the "convert a branch" picker. */ + onListBranches?: () => Promise + /** Open the review pane (changed files + diffs). */ + onOpen?: () => void + /** Jump into an existing worktree (open a fresh session anchored there). */ + onOpenWorktree?: (path: string) => void + /** Switch the current repo checkout to another branch. */ + onSwitchBranch?: (branch: string) => Promise +} + +/** + * The always-on coding-context row, the BASE of the composer status stack: + * current branch, dirty summary (+/-), and ahead/behind. A touch more prominent + * than the per-turn rows above it (larger branch label, accent glyph), and the + * entry point to the review pane. Hidden when the active session isn't in a + * local git repo (the probe returns null). + */ +export const CodingStatusRow = memo(function CodingStatusRow({ + onBranchOff, + onConvertBranch, + onListBranches, + onOpen, + onOpenWorktree, + onSwitchBranch +}: CodingStatusRowProps) { + const { t } = useI18n() + const s = t.statusStack.coding + const p = t.sidebar.projects + const status = useStore($repoStatus) + const worktrees = useStore($repoWorktrees) + + const [branchOpen, setBranchOpen] = useState(false) + const [branchName, setBranchName] = useState('') + const [branchBase, setBranchBase] = useState(undefined) + const [branchPending, setBranchPending] = useState(false) + // "Convert an existing branch into a worktree" sub-mode of the dialog: the body + // swaps the new-branch name input for a filterable list of the repo's branches. + const [convertMode, setConvertMode] = useState(false) + const [branches, setBranches] = useState([]) + const [branchesLoading, setBranchesLoading] = useState(false) + + // Pull the repo's branches the first time the convert picker is shown for an + // open dialog. Cheap + bounded; refreshed each time the picker is entered so a + // branch created mid-session shows up. + const loadBranches = useCallback(async () => { + if (!onListBranches) { + return + } + + setBranchesLoading(true) + + try { + setBranches(await onListBranches()) + } catch { + setBranches([]) + } finally { + setBranchesLoading(false) + } + }, [onListBranches]) + + // Open the name dialog for a chosen base. Deferred so the dropdown finishes + // closing before the dialog grabs focus (Radix focus-trap handoff races + // otherwise). + const startBranch = (base: string | undefined) => { + setBranchBase(base) + setBranchName('') + setConvertMode(false) + setTimeout(() => setBranchOpen(true), 0) + } + + // Open the dialog straight into the convert-a-branch picker. + const startConvert = () => { + setBranchBase(undefined) + setBranchName('') + setConvertMode(true) + void loadBranches() + setTimeout(() => setBranchOpen(true), 0) + } + + // Flip an already-open dialog into the picker (the in-dialog link). + const enterConvert = () => { + setConvertMode(true) + void loadBranches() + } + + const convertBranch = async (branch: HermesGitBranch) => { + if (branchPending || !branch || !onConvertBranch) { + return + } + + setBranchPending(true) + + try { + await onConvertBranch(branch.name, branch.worktreePath) + setBranchOpen(false) + } catch (err) { + notifyError(err, p.startWorkFailed) + } finally { + setBranchPending(false) + } + } + + // Global ⌘⇧B (workspace.newWorktree): open the name dialog for a worktree off + // current HEAD. The rail only renders inside a repo, so the hotkey naturally + // no-ops elsewhere. Guarded by a token ref so it fires on the keypress, not on + // mount or unrelated re-renders. + const worktreeReq = useStore($newWorktreeRequest) + const lastWorktreeReqRef = useRef(worktreeReq) + + useEffect(() => { + if (worktreeReq === lastWorktreeReqRef.current) { + return + } + + lastWorktreeReqRef.current = worktreeReq + + if (!onBranchOff) { + return + } + + setBranchBase(undefined) + setBranchName('') + setConvertMode(false) + setBranchOpen(true) + }, [onBranchOff, worktreeReq]) + + const submitBranch = async () => { + const branch = branchName.trim() + + if (branchPending || !branch || !onBranchOff) { + return + } + + setBranchPending(true) + + try { + await onBranchOff(branch, branchBase) + setBranchOpen(false) + setBranchName('') + } catch (err) { + notifyError(err, p.startWorkFailed) + } finally { + setBranchPending(false) + } + } + + const switchToBranch = async (branch: string) => { + if (!onSwitchBranch) { + return + } + + try { + await onSwitchBranch(branch) + } catch (err) { + notifyError(err, s.switchFailed(branch)) + } + } + + if (!status) { + return null + } + + const branchLabel = status.detached ? s.detached : status.branch || s.noBranch + // The kebab offers branching off the trunk and/or the current branch. The + // worktree-add bases the new branch on `base` (a branch name; undefined = + // current HEAD). We dedupe so "on main" shows a single trunk entry, and fall + // back to a plain off-HEAD branch when no trunk is detected. + const current = status.detached ? null : status.branch + const branchTargets: { base: string | undefined; label: string }[] = [] + + // Current branch first (the 99% "branch off where I am"), then the trunk just + // below it ("New branch from main"), deduped when they're the same. + if (current) { + branchTargets.push({ base: current, label: s.branchOffFrom(current) }) + } + + if (status.defaultBranch && status.defaultBranch !== current) { + branchTargets.push({ base: status.defaultBranch, label: s.branchOffFrom(status.defaultBranch) }) + } + + if (branchTargets.length === 0) { + branchTargets.push({ base: undefined, label: s.newBranch }) + } + + const switchTarget = onSwitchBranch && current && status.defaultBranch && status.defaultBranch !== current ? status.defaultBranch : null + + // Other worktrees to jump into — everything except the one we're already in + // (matched by its checked-out branch) and the bare/main placeholder entry. + const otherWorktrees = onOpenWorktree + ? worktrees.filter(w => w.path && !w.detached && w.branch && w.branch !== current) + : [] + + const hasLineDelta = status.added > 0 || status.removed > 0 + // Untracked files carry no line delta vs HEAD, so surface them as a count when + // they're the only change (otherwise +/- tells the story). + const untrackedOnly = !hasLineDelta && status.untracked > 0 + + return ( + <> + } + onActivate={onOpen} + > +
+ + {branchLabel} + + + {/* Branch actions kebab — same pattern as the session/worktree rows. + ALWAYS laid out; only its opacity flips on hover/focus/open, so + revealing it never reflows the row (no layout shift). pointer-events + follow opacity so the invisible trigger isn't clickable at rest. */} + {onBranchOff && ( + + + + + {/* The row sits at the bottom of the screen (above the composer), + so the menu opens upward. */} + + {s.newBranch} + {branchTargets.map(target => ( + startBranch(target.base)}> + {target.label} + + ))} + + {switchTarget && ( + void switchToBranch(switchTarget)}> + {s.switchTo(switchTarget)} + + )} + + + {s.worktrees} + {otherWorktrees.map(worktree => ( + onOpenWorktree?.(worktree.path)}> + {worktree.branch} + + ))} + {/* Create a fresh worktree off the current HEAD (the generic + "spin up a worktree here", mirroring the sidebar's + button). */} + startBranch(undefined)}> + {p.startWork} + + {/* Check an EXISTING branch out into a worktree (no new branch). */} + {onConvertBranch && ( + startConvert()}> + {p.convertBranch} + + )} + + + )} +
+ + {(status.ahead > 0 || status.behind > 0) && ( + + {status.ahead > 0 && ( + + + {status.ahead} + + )} + {status.behind > 0 && ( + + + {status.behind} + + )} + + )} + + {hasLineDelta ? ( + + ) : untrackedOnly ? ( + + {s.changed(status.untracked)} + + ) : null} +
+ + !branchPending && setBranchOpen(open)} open={branchOpen}> + + + {convertMode ? p.convertBranchTitle : p.newWorktreeTitle} + + {convertMode ? p.convertBranchDesc : p.newWorktreeDesc} + {!convertMode && branchBase && ( + {s.branchOffFrom(branchBase)} + )} + + + + {convertMode ? ( + (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)} + > + + + {branchesLoading ? p.branchesLoading : p.noBranches} + + {branches.map(branch => ( + void convertBranch(branch)} + value={branch.name} + > + + {branch.name} + {branch.checkedOut && ( + + {p.branchCheckedOut} + + )} + + ))} + + + + ) : ( + { + if (event.key === 'Enter') { + event.preventDefault() + void submitBranch() + } else if (event.key === 'Escape') { + setBranchOpen(false) + } + }} + onValueChange={setBranchName} + placeholder={p.branchPlaceholder} + sanitize={gitRef} + value={branchName} + /> + )} + + {convertMode ? ( + // The picker is a sub-screen: a single "Cancel" link steps back to + // the new-branch screen (the dialog's own ✕ / Esc still closes it). + + + + ) : ( + + {/* Switch into the convert-an-existing-branch picker. */} + {onConvertBranch ? ( + + ) : ( + + )} +
+ + +
+
+ )} +
+
+ + ) +}) diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx index b9cf2ffb99c..93c8a2dc1af 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -30,6 +30,19 @@ import { StatusItemRow } from './status-row' // emit no event when they die). Only armed while a running row is on screen. const BACKGROUND_POLL_MS = 5_000 +// A localhost/loopback preview is only meaningful while its dev server is up, so +// we tie it to a live background process rather than persisting dismissals or +// letting dead URLs pile up. File previews (a real on-disk artifact) stand alone. +const isLocalhostPreview = (target: string): boolean => /\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0)\b/i.test(target) + +// Real codicons per group (no sparkles): a checklist for todos, a bot for +// subagents, a background process glyph for background tasks. +const GROUP_ICON: Record = { + todo: 'checklist', + subagent: 'hubot', + background: 'server-process' +} + const groupLabel = (group: StatusGroup, s: Translations['statusStack']) => { if (group.type === 'todo') { return s.todos(group.items.filter(i => i.todoStatus === 'completed').length, group.items.length) @@ -74,6 +87,10 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro const hasRunningBackground = groups.some(g => g.type === 'background' && g.items.some(i => i.state === 'running')) + // Drop localhost previews once no dev server is left running — that's what made + // dead `localhost:5174` chips stick around. On-disk file previews are kept. + const visiblePreviews = previews.filter(item => hasRunningBackground || !isLocalhostPreview(item.target)) + useEffect(() => { if (!sessionId || !hasRunningBackground) { return @@ -89,6 +106,18 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro const openSubagent = (item: ComposerStatusItem) => item.sessionId ? void openSessionInNewWindow(item.sessionId, { watch: true }) : openAgents() + // Preview links live as child rows of the background group — a localhost dev + // server and its preview are the same thing — so they no longer float as an + // odd, differently-indented standalone block under the stack. + const previewRows = + visiblePreviews.length > 0 && sessionId + ? visiblePreviews.map(item => ( + dismissPreviewArtifact(sessionId, id)} /> + )) + : [] + + const hasBackgroundGroup = groups.some(g => g.type === 'background') + const sections: { key: string; node: ReactNode }[] = groups.map(group => ({ key: group.type, node: ( @@ -107,11 +136,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro ) : undefined } defaultCollapsed={group.type !== 'todo'} - icon={ - group.type === 'todo' ? ( - - ) : undefined - } + icon={} label={groupLabel(group, t.statusStack)} > {group.items.map(item => ( @@ -120,25 +145,20 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro key={item.id} onDismiss={sessionId ? id => dismissBackgroundProcess(sessionId, id) : undefined} onOpen={() => openSubagent(item)} - onStop={sessionId ? id => stopBackgroundProcess(sessionId, id) : undefined} + onStop={sessionId ? id => void stopBackgroundProcess(sessionId, id) : undefined} /> ))} + {group.type === 'background' && previewRows} ) })) - if (previews.length > 0 && sessionId) { + // No background group to host them (e.g. a standalone on-disk file preview): + // keep the previews as their own row block so they don't disappear. + if (previewRows.length > 0 && !hasBackgroundGroup) { sections.push({ key: 'preview', - // Not a collapsible group — preview links just sit there, one line each, - // each individually closeable. - node: ( -
- {previews.map(item => ( - dismissPreviewArtifact(sessionId, id)} /> - ))} -
- ) + node:
{previewRows}
}) } @@ -190,12 +210,10 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro return (
blurComposerInput()} ref={stackRef} > @@ -205,17 +223,19 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro Rounded top, square bottom; the bottom border is TRANSPARENT — the composer surface's visible top border (which sits at a higher z) is the single shared seam, so the two read as one fused capsule. */} -
-
- {sections.map(section => ( -
{section.node}
- ))} -
+
+ {sections.map(section => ( +
{section.node}
+ ))}
) diff --git a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx index cc6893f0e64..f8c3cc520b3 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx @@ -6,7 +6,6 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' -import { ChevronRight, X } from '@/lib/icons' import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' import { cn } from '@/lib/utils' import { PREVIEW_PANE_ID } from '@/store/layout' @@ -76,50 +75,47 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss return ( } - onActivate={() => void togglePreview()} + leading={ + + } + // Plain click opens the link in the browser; ⌘/Ctrl-click opens it in the + // in-app preview pane instead. (isOpen still toggles the pane closed.) + onActivate={event => { + if (event.metaKey || event.ctrlKey) { + void togglePreview() + } else { + void openInBrowser() + } + }} trailing={ - - - - - - - - + + + } trailingVisible > - - {item.label} - - - {opening ? t.preview.opening : isOpen ? t.preview.hide : t.preview.openPreview} - + + {item.target} + {t.preview.linkHint} + + } + > + {item.label} + ) }) diff --git a/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx index 27a9ef0262c..bc54b92ffe9 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx @@ -8,7 +8,6 @@ import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { Tip } from '@/components/ui/tooltip' import { type Translations, useI18n } from '@/i18n' -import { ArrowUpRight, X } from '@/lib/icons' import type { TodoStatus } from '@/lib/todos' import { cn } from '@/lib/utils' import type { ComposerStatusItem } from '@/store/composer-status' @@ -50,7 +49,7 @@ function leadingGlyph(item: ComposerStatusItem, s: Translations['statusStack']): return ( ) @@ -117,11 +116,11 @@ export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOp type="button" variant="ghost" > - + ) : canOpen ? ( - + ) : undefined } > diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx index 57358186a03..8dc67582f83 100644 --- a/apps/desktop/src/app/command-center/index.tsx +++ b/apps/desktop/src/app/command-center/index.tsx @@ -1,5 +1,4 @@ import { useStore } from '@nanostores/react' -import { IconBookmark, IconBookmarkFilled, IconDownload, IconTrash } from '@tabler/icons-react' import { type MouseEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { PageLoader } from '@/components/page-loader' @@ -17,7 +16,7 @@ import { import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes' import { useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' -import { Activity, AlertCircle, BarChart3, Pin } from '@/lib/icons' +import { Activity, AlertCircle, BarChart3, Bookmark, BookmarkFilled, Download, Pin, Trash2 } from '@/lib/icons' import { exportSession } from '@/lib/session-export' import { cn } from '@/lib/utils' import { upsertDesktopActionTask } from '@/store/activity' @@ -338,23 +337,23 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on title={pinned ? cc.unpinSession : cc.pinSession} > {pinned ? ( - + ) : ( - + )} void exportSession(session.id, { session, title: sessionTitle(session) })} title={cc.exportSession} > - + void onDeleteSession(session.id)} title={cc.deleteSession} > - +
diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 0f94ed13945..63b48ce8e98 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -21,6 +21,7 @@ import { Cpu, Download, Egg, + GitBranch, Globe, type IconComponent, Info, @@ -42,9 +43,11 @@ import { Zap } from '@/lib/icons' import { cn } from '@/lib/utils' +import { $repoWorktrees } from '@/store/coding-status' import { $commandPaletteOpen, $commandPalettePage, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette' import { $bindings } from '@/store/keybinds' import { openPetGenerate } from '@/store/pet-generate' +import { requestStartWorkSession } from '@/store/projects' import { runGatewayRestart } from '@/store/system-actions' import { luminance } from '@/themes/color' import { type ThemeMode, useTheme } from '@/themes/context' @@ -213,6 +216,7 @@ export function CommandPalette() { const open = useStore($commandPaletteOpen) const pendingPage = useStore($commandPalettePage) const bindings = useStore($bindings) + const worktrees = useStore($repoWorktrees) const navigate = useNavigate() const { availableThemes, resolvedMode, setMode, setTheme, themeName } = useTheme() const [search, setSearch] = useState('') @@ -291,6 +295,30 @@ export function CommandPalette() { const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}` const cc = t.commandCenter + // The active repo's worktrees → "new conversation in ". This is the + // ⌘K-typed "I want to work on " reflex: each entry seeds a fresh + // session anchored to that worktree's checkout (requestStartWorkSession), + // so git is the source of truth and edits land in the right tree. + const branchGroup: PaletteGroup[] = + worktrees.length > 0 + ? [ + { + heading: cc.branches, + items: worktrees.map(wt => { + const name = wt.branch?.trim() || wt.path.split('/').pop() || wt.path + + return { + icon: GitBranch, + id: `worktree-${wt.path}`, + keywords: ['branch', 'worktree', 'switch', name, wt.path], + label: cc.startInBranch(name), + run: () => requestStartWorkSession(wt.path) + } + }) + } + ] + : [] + return [ { heading: cc.goTo, @@ -352,6 +380,7 @@ export function CommandPalette() { { action: 'nav.agents', icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) } ] }, + ...branchGroup, { heading: cc.commandCenter, items: [ @@ -441,7 +470,7 @@ export function CommandPalette() { ] } ] - }, [go, settingsSectionLabel, t]) + }, [go, settingsSectionLabel, t, worktrees]) // The long, granular lists (settings fields, API keys, MCP servers, archived // chats) only surface once the user types — otherwise they'd bury the diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 817da734338..80370f2488b 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -6,6 +6,7 @@ import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell' import { matchesQuery } from '@/hooks/use-media-query' import { PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions' import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo' +import { $repoStatus } from '@/store/coding-status' import { toggleCommandPalette } from '@/store/command-palette' import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds' import { @@ -25,6 +26,8 @@ import { switchToDefaultProfile, toggleShowAllProfiles } from '@/store/profile' +import { requestNewWorktree } from '@/store/projects' +import { toggleReview } from '@/store/review' import { setModelPickerOpen } from '@/store/session' import { $switcherOpen, @@ -140,6 +143,9 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { ...sessionSlotHandlers, 'session.focusSearch': requestSessionSearchFocus, 'session.togglePin': deps.toggleSelectedPin, + // Only meaningful inside a git repo — a no-op otherwise (the key falls + // through instead of silently doing nothing). + 'workspace.newWorktree': () => $repoStatus.get() && requestNewWorktree(), 'view.toggleSidebar': () => { if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) { @@ -155,6 +161,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { toggleFileBrowserOpen() } }, + 'view.toggleReview': toggleReview, 'view.showFiles': showFiles, 'view.showTerminal': () => setTerminalTakeover(!$terminalTakeover.get()), 'view.flipPanes': togglePanesFlipped,