diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-branch.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-branch.ts new file mode 100644 index 00000000000..a8e5c65888c --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-branch.ts @@ -0,0 +1,95 @@ +import { type MutableRefObject, useCallback } from 'react' + +import { clearComposerAttachments } from '@/store/composer' +import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects' + +interface UseComposerBranchOptions { + clearDraft: () => void + cwd: null | string | undefined + draftRef: MutableRefObject +} + +/** + * Branch / worktree engine — the `CodingStatusRow` hand-offs. Each action opens + * a fresh session anchored in a worktree carrying the current composer draft as + * its first turn; clearing here means the draft travels to the new session + * instead of getting stashed under this one. Backend coupling (cwd + the + * projects store) is the only dependency; nothing about ChatBar's render. + */ +export function useComposerBranch({ clearDraft, cwd, draftRef }: UseComposerBranchOptions) { + // 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, draftRef] + ) + + // 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, isDefault?: boolean) => { + if (path?.trim()) { + openInWorktree(path) + + return + } + + const repoPath = cwd?.trim() + + if (repoPath && isDefault) { + await switchBranchInRepo(repoPath, branch) + openInWorktree(repoPath) + + return + } + + 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] + ) + + return { handleBranchOff, handleConvertBranch, handleListBranches, handleSwitchBranch, openInWorktree } +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.ts new file mode 100644 index 00000000000..37b3625a4f1 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.ts @@ -0,0 +1,54 @@ +import { useEffect, useRef } from 'react' + +import { triggerHaptic } from '@/lib/haptics' + +interface UseComposerEscCancelOptions { + awaitingInput: boolean + busy: boolean + onCancel: () => unknown +} + +/** + * Global Esc-to-cancel: stop the in-flight turn when the CHAT (not the composer + * input, which has its own handler) has focus — clicking into the transcript and + * hitting Esc stops the run, matching the Stop button. A latest-handler ref keeps + * the window listener registered exactly once while still reading fresh + * busy/awaitingInput/onCancel each press. + */ +export function useComposerEscCancel({ awaitingInput, busy, onCancel }: UseComposerEscCancelOptions) { + // 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. + const escCancelRef = useRef<(event: globalThis.KeyboardEvent) => void>(() => {}) + + escCancelRef.current = (event: globalThis.KeyboardEvent) => { + // `awaitingInput`: the turn is parked on a clarify / approval / sudo / secret + // prompt, which owns Esc (or is meant to persist) — never cancel the stream + // out from under it. + if (event.key !== 'Escape' || event.defaultPrevented || !busy || awaitingInput) { + 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) + }, []) +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-placeholder.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-placeholder.ts new file mode 100644 index 00000000000..0c2e4b61927 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-placeholder.ts @@ -0,0 +1,60 @@ +import { useEffect, useRef, useState } from 'react' + +import { useI18n } from '@/i18n' +import { resetBrowseState } from '@/store/composer-input-history' + +import { pickPlaceholder } from '../composer-utils' + +interface UseComposerPlaceholderOptions { + disabled: boolean + reconnecting: boolean + sessionId: null | string | undefined +} + +/** + * The composer's placeholder text. A resting starter (new session) / continuation + * (existing session) is picked once and only re-rolled when we genuinely move to + * a *different* conversation — the null→id persist of a freshly-started session + * keeps its starter so the text doesn't flip mid-stream. While the transport is + * down, it swaps to a reconnecting / starting message instead. + */ +export function useComposerPlaceholder({ disabled, reconnecting, sessionId }: UseComposerPlaceholderOptions): string { + const { t } = useI18n() + const newSessionPlaceholders = t.composer.newSessionPlaceholders + const followUpPlaceholders = t.composer.followUpPlaceholders + + const [restingPlaceholder, setRestingPlaceholder] = useState(() => + pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders) + ) + + const prevSessionIdRef = useRef(sessionId) + + useEffect(() => { + const prev = prevSessionIdRef.current + prevSessionIdRef.current = sessionId + + if (prev === sessionId) { + return + } + + // null → id: the new session we're already in just got persisted. Keep the + // starter we showed instead of swapping to a follow-up under the user. + if (prev == null && sessionId) { + return + } + + resetBrowseState(prev) + setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders)) + }, [followUpPlaceholders, newSessionPlaceholders, sessionId]) + + // When the transport is disabled it's because the gateway isn't open. + // Distinguish a cold start ("Starting Hermes...") from a dropped connection + // we're trying to restore. During reconnect, keep the textbox editable so a + // flaky network doesn't block drafting; only submit/backend actions stay + // disabled until the gateway is open again. + return disabled + ? reconnecting + ? t.composer.placeholderReconnecting + : t.composer.placeholderStarting + : restingPlaceholder +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts new file mode 100644 index 00000000000..3fb0d0372f8 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts @@ -0,0 +1,97 @@ +import { useStore } from '@nanostores/react' +import { type RefObject, useCallback, useEffect } from 'react' + +import { triggerHaptic } from '@/lib/haptics' +import { + $composerPopoutPosition, + $composerPoppedOut, + readPopoutBounds, + setComposerPopoutPosition, + setComposerPoppedOut +} from '@/store/composer-popout' +import { isSecondaryWindow } from '@/store/windows' + +import { useComposerPopoutGestures } from './use-popout-drag' + +interface UseComposerPopoutOptions { + composerRef: RefObject +} + +/** + * Pop-out engine: the docked↔floating state (a shared, persisted atom), the + * dock/float/toggle actions, the drag gestures, and the on-screen re-clamp. + * Secondary windows (the tiny Ctrl+Shift+N window, subagent watch windows) can't + * pop out — a floating composer makes no sense there and would yank the main + * window's composer out via the shared atom. + */ +export function useComposerPopout({ composerRef }: UseComposerPopoutOptions) { + const popoutAllowed = !isSecondaryWindow() + const poppedOut = useStore($composerPoppedOut) && popoutAllowed + const popoutPosition = useStore($composerPopoutPosition) + + const handleComposerPopOut = useCallback(() => { + triggerHaptic('open') + setComposerPoppedOut(true) + }, []) + + const handleComposerDock = useCallback(() => { + triggerHaptic('success') + setComposerPoppedOut(false) + }, []) + + // Double-click the grab area toggles dock/float. Undocking restores the last + // position (the persisted atom is never cleared on dock). + const handleComposerToggle = useCallback(() => { + poppedOut ? handleComposerDock() : handleComposerPopOut() + }, [handleComposerDock, handleComposerPopOut, poppedOut]) + + const { + dockProximity, + dragging, + onPointerDown: onComposerGesturePointerDown + } = useComposerPopoutGestures({ + composerRef, + onDock: handleComposerDock, + onPopOut: handleComposerPopOut, + poppedOut, + position: popoutPosition + }) + + // Keep the floating box on-screen: re-clamp (with the real measured size + + // thread bounds) when it pops out and on every window resize — so a position + // persisted on a bigger/other monitor, a shrunk window, or now-wider sidebar + // can never strand it. The rAF pass re-clamps after layout settles (sidebar + // widths, fonts), so anyone loading in out of bounds is pulled back + saved + // even if the first measure was premature. + useEffect(() => { + if (!poppedOut) { + return undefined + } + + const reclamp = (persist: boolean) => { + const el = composerRef.current + const size = el ? { height: el.offsetHeight, width: el.offsetWidth } : undefined + setComposerPopoutPosition($composerPopoutPosition.get(), { area: readPopoutBounds(el), persist, size }) + } + + reclamp(true) + const raf = requestAnimationFrame(() => reclamp(true)) + const onResize = () => reclamp(false) + window.addEventListener('resize', onResize) + + return () => { + cancelAnimationFrame(raf) + window.removeEventListener('resize', onResize) + } + }, [composerRef, poppedOut]) + + return { + dockProximity, + dragging, + handleComposerToggle, + onComposerGesturePointerDown, + popoutAllowed, + popoutPosition, + poppedOut + } +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-url-dialog.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-url-dialog.test.tsx new file mode 100644 index 00000000000..060dd86514e --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-url-dialog.test.tsx @@ -0,0 +1,48 @@ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { useComposerUrlDialog } from './use-composer-url-dialog' + +vi.mock('@/lib/haptics', () => ({ triggerHaptic: () => {} })) + +describe('useComposerUrlDialog', () => { + it('drops an @url: directive into the draft when there is no host onAddUrl', () => { + const insertText = vi.fn() + const { result } = renderHook(() => useComposerUrlDialog({ insertText })) + + act(() => result.current.setUrlValue(' https://example.dev ')) + act(() => result.current.submitUrl()) + + // The trailing/leading whitespace is trimmed before building the directive. + expect(insertText).toHaveBeenCalledWith('@url:https://example.dev') + }) + + it('prefers the host onAddUrl handler, then clears + closes the dialog', () => { + const insertText = vi.fn() + const onAddUrl = vi.fn() + const { result } = renderHook(() => useComposerUrlDialog({ insertText, onAddUrl })) + + act(() => { + result.current.openUrlDialog() + result.current.setUrlValue(' https://example.dev ') + }) + act(() => result.current.submitUrl()) + + expect(onAddUrl).toHaveBeenCalledWith('https://example.dev') + expect(insertText).not.toHaveBeenCalled() + expect(result.current.urlValue).toBe('') + expect(result.current.urlOpen).toBe(false) + }) + + it('no-ops on an empty / whitespace-only URL', () => { + const insertText = vi.fn() + const onAddUrl = vi.fn() + const { result } = renderHook(() => useComposerUrlDialog({ insertText, onAddUrl })) + + act(() => result.current.setUrlValue(' ')) + act(() => result.current.submitUrl()) + + expect(insertText).not.toHaveBeenCalled() + expect(onAddUrl).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-url-dialog.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-url-dialog.ts new file mode 100644 index 00000000000..889ed70a06d --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-url-dialog.ts @@ -0,0 +1,50 @@ +import { useEffect, useRef, useState } from 'react' + +import { triggerHaptic } from '@/lib/haptics' + +interface UseComposerUrlDialogOptions { + insertText: (text: string) => void + onAddUrl?: (url: string) => void +} + +/** + * "Add URL" dialog engine: open/value state, autofocus-on-open, and submit. On + * submit it prefers the host's `onAddUrl` (which may fetch/title the link) and + * otherwise drops an `@url:` directive into the draft. + */ +export function useComposerUrlDialog({ insertText, onAddUrl }: UseComposerUrlDialogOptions) { + const urlInputRef = useRef(null) + const [urlOpen, setUrlOpen] = useState(false) + const [urlValue, setUrlValue] = useState('') + + useEffect(() => { + if (urlOpen) { + window.requestAnimationFrame(() => urlInputRef.current?.focus({ preventScroll: true })) + } + }, [urlOpen]) + + const openUrlDialog = () => { + triggerHaptic('open') + setUrlOpen(true) + } + + const submitUrl = () => { + const url = urlValue.trim() + + if (!url) { + return + } + + if (onAddUrl) { + onAddUrl(url) + } else { + insertText(`@url:${url}`) + } + + triggerHaptic('success') + setUrlValue('') + setUrlOpen(false) + } + + return { openUrlDialog, setUrlOpen, setUrlValue, submitUrl, urlInputRef, urlOpen, urlValue } +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index d49c8382b7b..5503de7a356 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -20,37 +20,28 @@ import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' -import { $composerAttachments, clearComposerAttachments } from '@/store/composer' +import { $composerAttachments } from '@/store/composer' import { browseBackward, browseForward, deriveUserHistory, - isBrowsingHistory, - resetBrowseState + isBrowsingHistory } from '@/store/composer-input-history' import { - $composerPopoutPosition, - $composerPoppedOut, - POPOUT_WIDTH_REM, - readPopoutBounds, - setComposerPopoutPosition, - setComposerPoppedOut + POPOUT_WIDTH_REM } from '@/store/composer-popout' import { removeQueuedPrompt } from '@/store/composer-queue' -import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects' import { $activeSessionAwaitingInput } from '@/store/prompts' import { toggleReview } from '@/store/review' import { $gatewayState, $messages } from '@/store/session' import { $threadScrolledUp } from '@/store/thread-scroll' import { $autoSpeakReplies } from '@/store/voice-prefs' -import { isSecondaryWindow } from '@/store/windows' import { useTheme } from '@/themes' import { AttachmentList } from './attachments' import { COMPLETION_ACTIONS, COMPOSER_FADE_BACKGROUND, - pickPlaceholder, type QueueEditState, slashArgStage, slashChipKindForItem, @@ -62,13 +53,17 @@ import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-aff import { markActiveComposer } from './focus' import { HelpHint } from './help-hint' import { useAtCompletions } from './hooks/use-at-completions' +import { useComposerBranch } from './hooks/use-composer-branch' import { useComposerDraft } from './hooks/use-composer-draft' import { useComposerDrop } from './hooks/use-composer-drop' +import { useComposerEscCancel } from './hooks/use-composer-esc-cancel' import { useComposerMetrics } from './hooks/use-composer-metrics' +import { useComposerPlaceholder } from './hooks/use-composer-placeholder' +import { useComposerPopout } from './hooks/use-composer-popout' import { useComposerQueue } from './hooks/use-composer-queue' import { useComposerSubmit } from './hooks/use-composer-submit' +import { useComposerUrlDialog } from './hooks/use-composer-url-dialog' import { useComposerVoice } from './hooks/use-composer-voice' -import { useComposerPopoutGestures } from './hooks/use-popout-drag' import { useSlashCompletions } from './hooks/use-slash-completions' import { useSessionStatusPresence } from './hooks/use-status-presence' import { QueuePanel } from './queue-panel' @@ -123,13 +118,6 @@ export function ChatBar({ // would discard a question the user may want to come back to. The blocking // prompt owns its own dismissal (Skip, Reject, dialog close). const awaitingInput = useStore($activeSessionAwaitingInput) - // Pop-out is a shared, persisted state — but secondary windows (the Ctrl+Shift+N - // tiny window, subagent watch windows) always start docked and can't pop out: - // a floating composer makes no sense in a single-session side window, and it - // would otherwise write the shared atom and yank the main window's composer out. - const popoutAllowed = !isSecondaryWindow() - const poppedOut = useStore($composerPoppedOut) && popoutAllowed - const popoutPosition = useStore($composerPopoutPosition) const activeQueueSessionKey = queueSessionKey || sessionId || null // Status items (subagents, background processes) are keyed by the RUNTIME @@ -144,38 +132,18 @@ export function ChatBar({ const composerRef = useRef(null) const composerSurfaceRef = useRef(null) - const handleComposerPopOut = useCallback(() => { - triggerHaptic('open') - setComposerPoppedOut(true) - }, []) - - const handleComposerDock = useCallback(() => { - triggerHaptic('success') - setComposerPoppedOut(false) - }, []) - - // Double-click the grab area toggles dock/float. Undocking restores the last - // position (the persisted atom is never cleared on dock). - const handleComposerToggle = useCallback(() => { - poppedOut ? handleComposerDock() : handleComposerPopOut() - }, [handleComposerDock, handleComposerPopOut, poppedOut]) - + // Pop-out engine: docked↔floating state, dock/float/toggle, drag gestures, and + // the on-screen re-clamp. Secondary windows can't pop out. const { dockProximity, dragging, - onPointerDown: onComposerGesturePointerDown - } = useComposerPopoutGestures({ - composerRef, - onDock: handleComposerDock, - onPopOut: handleComposerPopOut, - poppedOut, - position: popoutPosition - }) + handleComposerToggle, + onComposerGesturePointerDown, + popoutAllowed, + popoutPosition, + poppedOut + } = useComposerPopout({ composerRef }) - const urlInputRef = useRef(null) - - const [urlOpen, setUrlOpen] = useState(false) - const [urlValue, setUrlValue] = useState('') // Coordinator-owned: the draft engine reads the live queue-edit snapshot off // this ref (to suppress its stash while editing a queued prompt) and the queue // engine writes it — an explicit shared handle, not a back-reference. @@ -188,8 +156,6 @@ export function ChatBar({ const { t } = useI18n() const gatewayState = useStore($gatewayState) - const newSessionPlaceholders = t.composer.newSessionPlaceholders - const followUpPlaceholders = t.composer.followUpPlaceholders const reconnecting = gatewayState === 'closed' || gatewayState === 'error' const inputDisabled = disabled && !reconnecting @@ -214,6 +180,13 @@ export function ChatBar({ stashAt } = useComposerDraft({ activeQueueSessionKey, focusKey, inputDisabled, queueEditRef, sessionId }) + // "Add URL" dialog — open/value state, autofocus, and submit (host onAddUrl or + // an @url: directive into the draft). + const { openUrlDialog, setUrlOpen, setUrlValue, submitUrl, urlInputRef, urlOpen, urlValue } = useComposerUrlDialog({ + insertText, + onAddUrl + }) + // The queue engine — queued turns, in-place editing, the shared drain lock, // and bounded auto-drain. Consumes the draft API and writes `queueEditRef`. const { @@ -282,79 +255,9 @@ export function ChatBar({ stashAt }) - // Resting placeholder: a starter for brand-new sessions, a continuation for - // existing ones. Picked once and only re-rolled when we genuinely move to a - // *different* conversation. Critically, the first id assignment of a freshly - // started session (null → id, on the first send) is treated as the same - // conversation so the placeholder doesn't visibly flip mid-stream. - const [restingPlaceholder, setRestingPlaceholder] = useState(() => - pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders) - ) - - const prevSessionIdRef = useRef(sessionId) - - useEffect(() => { - const prev = prevSessionIdRef.current - prevSessionIdRef.current = sessionId - - if (prev === sessionId) { - return - } - - // null → id: the new session we're already in just got persisted. Keep the - // starter we showed instead of swapping to a follow-up under the user. - if (prev == null && sessionId) { - return - } - - resetBrowseState(prev) - setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders)) - }, [followUpPlaceholders, newSessionPlaceholders, sessionId]) - - // When the transport is disabled it's because the gateway isn't open. - // Distinguish a cold start ("Starting Hermes...") from a dropped connection - // we're trying to restore. During reconnect, keep the textbox editable so a - // flaky network doesn't block drafting; only submit/backend actions stay - // disabled until the gateway is open again. - const placeholder = disabled - ? reconnecting - ? t.composer.placeholderReconnecting - : t.composer.placeholderStarting - : restingPlaceholder - - useEffect(() => { - if (urlOpen) { - window.requestAnimationFrame(() => urlInputRef.current?.focus({ preventScroll: true })) - } - }, [urlOpen]) - - // Keep the floating box on-screen: re-clamp (with the real measured size + - // thread bounds) when it pops out and on every window resize — so a position - // persisted on a bigger/other monitor, a shrunk window, or now-wider sidebar - // can never strand it. The rAF pass re-clamps after layout settles (sidebar - // widths, fonts), so anyone loading in out of bounds is pulled back + saved - // even if the first measure was premature. - useEffect(() => { - if (!poppedOut) { - return undefined - } - - const reclamp = (persist: boolean) => { - const el = composerRef.current - const size = el ? { height: el.offsetHeight, width: el.offsetWidth } : undefined - setComposerPopoutPosition($composerPopoutPosition.get(), { area: readPopoutBounds(el), persist, size }) - } - - reclamp(true) - const raf = requestAnimationFrame(() => reclamp(true)) - const onResize = () => reclamp(false) - window.addEventListener('resize', onResize) - - return () => { - cancelAnimationFrame(raf) - window.removeEventListener('resize', onResize) - } - }, [poppedOut]) + // Resting / reconnecting / starting placeholder text, re-rolled only on a real + // conversation change. + const placeholder = useComposerPlaceholder({ disabled, reconnecting, sessionId }) const [trigger, setTrigger] = useState(null) const [triggerActive, setTriggerActive] = useState(0) @@ -966,137 +869,13 @@ export function ChatBar({ handleInputDrop } = useComposerDrop({ cwd, insertInlineRefs, onAttachDroppedItems, requestMainFocus }) - // 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 / worktree hand-offs (CodingStatusRow). Owns the worktree open + + // branch-off/convert/list/switch actions; draft travels into the new session. + const { handleBranchOff, handleConvertBranch, handleListBranches, handleSwitchBranch, openInWorktree } = + useComposerBranch({ clearDraft, cwd, draftRef }) - // 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, isDefault?: boolean) => { - if (path?.trim()) { - openInWorktree(path) - - return - } - - const repoPath = cwd?.trim() - - if (repoPath && isDefault) { - await switchBranchInRepo(repoPath, branch) - openInWorktree(repoPath) - - return - } - - 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] - ) - - // 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) => { - // `awaitingInput`: the turn is parked on a clarify / approval / sudo / secret - // prompt, which owns Esc (or is meant to persist) — never cancel the stream - // out from under it. - if (event.key !== 'Escape' || event.defaultPrevented || !busy || awaitingInput) { - 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) - }, []) - - const submitUrl = () => { - const url = urlValue.trim() - - if (!url) { - return - } - - if (onAddUrl) { - onAddUrl(url) - } else { - insertText(`@url:${url}`) - } - - triggerHaptic('success') - setUrlValue('') - setUrlOpen(false) - } + // Global Esc-to-cancel when the chat (not the composer input) has focus. + useComposerEscCancel({ awaitingInput, busy, onCancel }) const { conversation, @@ -1122,10 +901,7 @@ export function ChatBar({ const contextMenu = ( { - triggerHaptic('open') - setUrlOpen(true) - }} + onOpenUrlDialog={openUrlDialog} onPasteClipboardImage={onPasteClipboardImage} onPickFiles={onPickFiles} onPickFolders={onPickFolders}