mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
feat(desktop): add composer coding rail and worktree flow
This commit is contained in:
parent
488ae376db
commit
7a7f9a5b3d
9 changed files with 765 additions and 90 deletions
|
|
@ -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<InsertRefsDetail>(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<SubmitDetail>(SUBMIT_EVENT, { target: resolve(target), text: trimmed })
|
||||
}
|
||||
}
|
||||
|
||||
export const onComposerSubmitRequest = (handler: (detail: SubmitDetail) => void) =>
|
||||
subscribe<SubmitDetail>(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() })
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<div className="relative w-full rounded-[inherit]">
|
||||
<div
|
||||
className={cn(
|
||||
'group/composer-surface relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] transition-[border-color] duration-200 ease-out focus-within:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]',
|
||||
'group/composer-surface relative z-4 isolate grid grid-rows-[auto_1fr] overflow-hidden rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))]',
|
||||
COMPOSER_DROP_FADE_CLASS,
|
||||
dragActive && COMPOSER_DROP_ACTIVE_CLASS
|
||||
)}
|
||||
|
|
@ -2114,6 +2235,14 @@ export function ChatBar({
|
|||
composerSurfaceGlass
|
||||
)}
|
||||
/>
|
||||
<CodingStatusRow
|
||||
onBranchOff={handleBranchOff}
|
||||
onConvertBranch={handleConvertBranch}
|
||||
onListBranches={handleListBranches}
|
||||
onOpen={toggleReview}
|
||||
onOpenWorktree={openInWorktree}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
|
||||
|
|
|
|||
473
apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx
Normal file
473
apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { StatusRow } from '@/components/chat/status-row'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { DiffCount } from '@/components/ui/diff-count'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { SanitizedInput } from '@/components/ui/sanitized-input'
|
||||
import type { HermesGitBranch } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { gitRef } from '@/lib/sanitize'
|
||||
import { $repoStatus, $repoWorktrees } from '@/store/coding-status'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $newWorktreeRequest } from '@/store/projects'
|
||||
|
||||
// Tiny uppercase section header, matching the composer "+" menu's labels.
|
||||
const MENU_SECTION = 'text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)'
|
||||
|
||||
interface CodingStatusRowProps {
|
||||
/** Branch the current draft off into a fresh worktree + session, based on
|
||||
* `base` (a branch name; omitted = current HEAD). The composer owns the
|
||||
* draft, so it supplies the orchestration; the row just collects the new
|
||||
* branch name + base. Omitted (e.g. remote backend) hides the affordance. */
|
||||
onBranchOff?: (branch: string, base?: string) => Promise<void>
|
||||
/** 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<void>
|
||||
/** List the repo's local branches for the "convert a branch" picker. */
|
||||
onListBranches?: () => Promise<HermesGitBranch[]>
|
||||
/** 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<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string | undefined>(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<HermesGitBranch[]>([])
|
||||
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 (
|
||||
<>
|
||||
<StatusRow
|
||||
// The base "where am I working" strip is part of the composer surface
|
||||
// itself, so it inherits the composer's width and clipped top radius.
|
||||
className="coding-status-bar min-h-7 rounded-t-[inherit] rounded-b-none border-b border-(--ui-stroke-tertiary) px-3.5 py-1.5 hover:bg-transparent"
|
||||
// Static branch glyph — never the loading spinner. This row only renders
|
||||
// once `status` exists, so a spinner here only ever fired on *refreshes*
|
||||
// of an already-loaded repo (window focus, turn settle), reading as an
|
||||
// annoying icon "blip" with no first-load value. Refreshes are silent.
|
||||
leading={<Codicon className="text-(--ui-green)" name="git-branch" size="0.8rem" />}
|
||||
onActivate={onOpen}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<span
|
||||
className="min-w-0 truncate text-xs font-normal text-muted-foreground/92 transition-colors group-hover/status-row:text-foreground/90"
|
||||
title={branchLabel}
|
||||
>
|
||||
{branchLabel}
|
||||
</span>
|
||||
|
||||
{/* 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 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={s.newBranch}
|
||||
className="pointer-events-none size-4 shrink-0 text-muted-foreground/60 opacity-0 transition hover:text-foreground group-hover/status-row:pointer-events-auto group-hover/status-row:opacity-100 group-focus-within/status-row:pointer-events-auto group-focus-within/status-row:opacity-100 data-[state=open]:pointer-events-auto data-[state=open]:opacity-100"
|
||||
onClick={event => event.stopPropagation()}
|
||||
onKeyDown={event => {
|
||||
// The row's onActivate also fires on Enter/Space; keep it from
|
||||
// opening the review pane when the kebab is the focus target.
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.stopPropagation()
|
||||
}
|
||||
}}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="kebab-vertical" size="0.8rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
{/* The row sits at the bottom of the screen (above the composer),
|
||||
so the menu opens upward. */}
|
||||
<DropdownMenuContent align="end" className="w-60" side="top" sideOffset={6}>
|
||||
<DropdownMenuLabel className={MENU_SECTION}>{s.newBranch}</DropdownMenuLabel>
|
||||
{branchTargets.map(target => (
|
||||
<DropdownMenuItem key={target.base ?? '__head__'} onSelect={() => startBranch(target.base)}>
|
||||
<span className="truncate">{target.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
{switchTarget && (
|
||||
<DropdownMenuItem onSelect={() => void switchToBranch(switchTarget)}>
|
||||
<span className="truncate">{s.switchTo(switchTarget)}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className={MENU_SECTION}>{s.worktrees}</DropdownMenuLabel>
|
||||
{otherWorktrees.map(worktree => (
|
||||
<DropdownMenuItem key={worktree.path} onSelect={() => onOpenWorktree?.(worktree.path)}>
|
||||
<span className="truncate">{worktree.branch}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{/* Create a fresh worktree off the current HEAD (the generic
|
||||
"spin up a worktree here", mirroring the sidebar's + button). */}
|
||||
<DropdownMenuItem onSelect={() => startBranch(undefined)}>
|
||||
<span className="truncate">{p.startWork}</span>
|
||||
</DropdownMenuItem>
|
||||
{/* Check an EXISTING branch out into a worktree (no new branch). */}
|
||||
{onConvertBranch && (
|
||||
<DropdownMenuItem onSelect={() => startConvert()}>
|
||||
<span className="truncate">{p.convertBranch}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(status.ahead > 0 || status.behind > 0) && (
|
||||
<span className="ml-auto flex shrink-0 items-center gap-1.5 text-[0.68rem] leading-4 text-muted-foreground/75 tabular-nums">
|
||||
{status.ahead > 0 && (
|
||||
<span className="flex items-center gap-0.5" title={s.ahead(status.ahead)}>
|
||||
<span aria-hidden>↑</span>
|
||||
{status.ahead}
|
||||
</span>
|
||||
)}
|
||||
{status.behind > 0 && (
|
||||
<span className="flex items-center gap-0.5" title={s.behind(status.behind)}>
|
||||
<span aria-hidden>↓</span>
|
||||
{status.behind}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{hasLineDelta ? (
|
||||
<DiffCount
|
||||
added={status.added}
|
||||
className={`text-[0.72rem] leading-4 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
|
||||
removed={status.removed}
|
||||
/>
|
||||
) : untrackedOnly ? (
|
||||
<span
|
||||
className={`shrink-0 text-[0.72rem] leading-4 text-amber-500/90 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
|
||||
>
|
||||
{s.changed(status.untracked)}
|
||||
</span>
|
||||
) : null}
|
||||
</StatusRow>
|
||||
|
||||
<Dialog onOpenChange={open => !branchPending && setBranchOpen(open)} open={branchOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{convertMode ? p.convertBranchTitle : p.newWorktreeTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{convertMode ? p.convertBranchDesc : p.newWorktreeDesc}
|
||||
{!convertMode && branchBase && (
|
||||
<span className="mt-1 block text-(--ui-text-secondary)">{s.branchOffFrom(branchBase)}</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{convertMode ? (
|
||||
<Command
|
||||
className="rounded-md border border-(--ui-stroke-tertiary)"
|
||||
// The branch name is the authoritative key; filter on it directly.
|
||||
filter={(value, search) => (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}
|
||||
>
|
||||
<CommandInput autoFocus disabled={branchPending} placeholder={p.convertBranchPlaceholder} />
|
||||
<CommandList className="max-h-64">
|
||||
<CommandEmpty>{branchesLoading ? p.branchesLoading : p.noBranches}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{branches.map(branch => (
|
||||
<CommandItem
|
||||
disabled={branchPending}
|
||||
key={branch.name}
|
||||
onSelect={() => void convertBranch(branch)}
|
||||
value={branch.name}
|
||||
>
|
||||
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="git-branch" size="0.8rem" />
|
||||
<span className="truncate">{branch.name}</span>
|
||||
{branch.checkedOut && (
|
||||
<span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">
|
||||
{p.branchCheckedOut}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
) : (
|
||||
<SanitizedInput
|
||||
autoFocus
|
||||
disabled={branchPending}
|
||||
onKeyDown={event => {
|
||||
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).
|
||||
<DialogFooter className="sm:justify-start">
|
||||
<Button
|
||||
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
|
||||
disabled={branchPending}
|
||||
onClick={() => setConvertMode(false)}
|
||||
type="button"
|
||||
variant="link"
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
) : (
|
||||
<DialogFooter className="sm:justify-between">
|
||||
{/* Switch into the convert-an-existing-branch picker. */}
|
||||
{onConvertBranch ? (
|
||||
<Button
|
||||
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
|
||||
disabled={branchPending}
|
||||
onClick={enterConvert}
|
||||
type="button"
|
||||
variant="link"
|
||||
>
|
||||
{p.convertBranchInstead}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button disabled={branchPending} onClick={() => setBranchOpen(false)} type="button" variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={branchPending || !branchName.trim()}
|
||||
onClick={() => void submitBranch()}
|
||||
type="button"
|
||||
>
|
||||
{p.startWork}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
|
@ -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<StatusGroup['type'], string> = {
|
||||
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 => (
|
||||
<PreviewStatusRow item={item} key={item.id} onDismiss={id => 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' ? (
|
||||
<Codicon className="text-muted-foreground/70" name="checklist" size="0.8rem" />
|
||||
) : undefined
|
||||
}
|
||||
icon={<Codicon className="text-muted-foreground/70" name={GROUP_ICON[group.type]} size="0.8rem" />}
|
||||
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}
|
||||
</StatusSection>
|
||||
)
|
||||
}))
|
||||
|
||||
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: (
|
||||
<div className="px-1 py-0.5">
|
||||
{previews.map(item => (
|
||||
<PreviewStatusRow item={item} key={item.id} onDismiss={id => dismissPreviewArtifact(sessionId, id)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
node: <div className="px-1 py-0.5">{previewRows}</div>
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -190,12 +210,10 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
|||
|
||||
return (
|
||||
<div
|
||||
// Sits above the composer (bottom-full), nudged down by the shell's 0.5rem
|
||||
// top pad (pt-2 on composer-root) plus 1px so its bottom edge overlaps the
|
||||
// composer surface's top border. z BELOW the surface (z-4) so the surface's
|
||||
// top border paints over our transparent bottom border — one seam, no
|
||||
// double line.
|
||||
className="absolute inset-x-0 bottom-full z-3 max-h-[40vh] translate-y-[calc(0.5rem+1px)] overflow-y-auto"
|
||||
// Sits in the overlay lane above the composer. The composer root has pt-2
|
||||
// before the actual surface; translate by that amount so the stack returns
|
||||
// to its original attachment point without intruding into the repo strip.
|
||||
className="absolute inset-x-0 bottom-full z-3 max-h-[40vh] translate-y-2 overflow-y-auto"
|
||||
onPointerDownCapture={() => 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. */}
|
||||
<div className={cn(composerDockCard('top'), 'mx-2 rounded-b-none border-b border-b-transparent pt-0.5 pb-1')}>
|
||||
<div
|
||||
className={cn(
|
||||
'transition-opacity duration-200 ease-out',
|
||||
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
{sections.map(section => (
|
||||
<div key={section.key}>{section.node}</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
composerDockCard('top'),
|
||||
// Inset (mx-2) so the stack reads slightly narrower than the composer
|
||||
// surface below it — the original look.
|
||||
'mx-2 overflow-hidden rounded-b-none border-b border-b-transparent pt-0.5',
|
||||
'transition-opacity duration-200 ease-out',
|
||||
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
{sections.map(section => (
|
||||
<div key={section.key}>{section.node}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<StatusRow
|
||||
leading={<ChevronRight aria-hidden className="size-3 text-muted-foreground/80" />}
|
||||
onActivate={() => void togglePreview()}
|
||||
leading={
|
||||
<Codicon aria-hidden className={cn('text-muted-foreground/70', opening && 'animate-pulse')} name="globe" size="0.8rem" />
|
||||
}
|
||||
// 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={
|
||||
<span className="-my-1 flex items-center gap-0.5">
|
||||
<Tip label={t.preview.openInBrowser}>
|
||||
<Button
|
||||
aria-label={t.preview.openInBrowser}
|
||||
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
void openInBrowser()
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="link-external" size="0.75rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={t.statusStack.dismiss}>
|
||||
<Button
|
||||
aria-label={t.statusStack.dismiss}
|
||||
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
onDismiss(item.id)
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</Tip>
|
||||
</span>
|
||||
<Tip label={t.statusStack.dismiss}>
|
||||
<Button
|
||||
aria-label={t.statusStack.dismiss}
|
||||
className="-my-1 size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
onDismiss(item.id)
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
}
|
||||
trailingVisible
|
||||
>
|
||||
<span className="min-w-0 max-w-[18rem] truncate text-[0.73rem] leading-4 text-foreground/92" title={item.target}>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className={cn('shrink-0 text-[0.62rem] leading-4 text-muted-foreground/70', opening && 'animate-pulse')}>
|
||||
{opening ? t.preview.opening : isOpen ? t.preview.hide : t.preview.openPreview}
|
||||
</span>
|
||||
<Tip
|
||||
label={
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span>{item.target}</span>
|
||||
<span className="opacity-70">{t.preview.linkHint}</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span className="min-w-0 max-w-[18rem] truncate text-[0.73rem] leading-4 text-foreground/92">{item.label}</span>
|
||||
</Tip>
|
||||
</StatusRow>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<GlyphSpinner
|
||||
ariaLabel={s.running}
|
||||
className="text-[0.9rem] leading-none text-muted-foreground/80"
|
||||
className="text-[0.85rem] leading-none text-muted-foreground/80"
|
||||
spinner="braille"
|
||||
/>
|
||||
)
|
||||
|
|
@ -117,11 +116,11 @@ export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOp
|
|||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X size={12} />
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
) : canOpen ? (
|
||||
<ArrowUpRight aria-hidden className="size-3.5 text-muted-foreground/55" />
|
||||
<Codicon aria-hidden className="text-muted-foreground/55" name="link-external" size="0.85rem" />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<IconBookmarkFilled className="size-3.5" />
|
||||
<BookmarkFilled className="size-3.5" />
|
||||
) : (
|
||||
<IconBookmark className="size-3.5" />
|
||||
<Bookmark className="size-3.5" />
|
||||
)}
|
||||
</RowIconButton>
|
||||
<RowIconButton
|
||||
onClick={() => void exportSession(session.id, { session, title: sessionTitle(session) })}
|
||||
title={cc.exportSession}
|
||||
>
|
||||
<IconDownload className="size-3.5" />
|
||||
<Download className="size-3.5" />
|
||||
</RowIconButton>
|
||||
<RowIconButton
|
||||
className="hover:text-destructive"
|
||||
onClick={() => void onDeleteSession(session.id)}
|
||||
title={cc.deleteSession}
|
||||
>
|
||||
<IconTrash className="size-3.5" />
|
||||
<Trash2 className="size-3.5" />
|
||||
</RowIconButton>
|
||||
</div>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -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 <branch>". This is the
|
||||
// ⌘K-typed "I want to work on <branch>" 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue