Skip to content
95 changes: 95 additions & 0 deletions apps/desktop/src/app/chat/composer/hooks/use-composer-branch.ts
Original file line number Diff line number Diff line change
@@ -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<string>
}

/**
* 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 }
}
Original file line number Diff line number Diff line change
@@ -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)
}, [])
}
Original file line number Diff line number Diff line change
@@ -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
}
97 changes: 97 additions & 0 deletions apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLFormElement | null>
}

/**
* 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
}
}
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading
Loading