mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Merge remote-tracking branch 'origin/main' into bb/yolo-palette
# Conflicts: # apps/desktop/src/app/command-palette/index.tsx
This commit is contained in:
commit
640056704c
46 changed files with 1533 additions and 410 deletions
|
|
@ -895,6 +895,17 @@ fn update_child_env(install_root: &Path) -> Vec<(String, OsString)> {
|
|||
// a frozen stage, and users cancel a healthy update. Force line-by-line
|
||||
// output instead.
|
||||
envs.push(("PYTHONUNBUFFERED".to_string(), OsString::from("1")));
|
||||
// We hold the update-in-progress marker for this whole run, and the
|
||||
// `hermes update` child claims that SAME lock (hermes_cli/update_lock.py).
|
||||
// Name our pid so the child recognizes the live holder as its own
|
||||
// orchestrator and runs under our claim — without this every GUI update
|
||||
// refuses its parent's marker with exit 2 ("Hermes is still running")
|
||||
// and no number of retries can ever succeed. Keep the variable name in
|
||||
// sync with HANDOFF_PID_ENV in hermes_cli/update_lock.py.
|
||||
envs.push((
|
||||
"HERMES_UPDATE_HANDOFF_PID".to_string(),
|
||||
OsString::from(std::process::id().to_string()),
|
||||
));
|
||||
if let Some(path) = path_with_prepended_entries(&[
|
||||
hermes_home.join("node").join("bin"),
|
||||
venv_bin_dir(install_root),
|
||||
|
|
@ -1218,6 +1229,17 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_child_env_names_our_pid_for_the_lock_handoff() {
|
||||
let envs = update_child_env(Path::new("/x/hermes-agent"));
|
||||
assert!(
|
||||
envs.iter().any(|(k, v)| k == "HERMES_UPDATE_HANDOFF_PID"
|
||||
&& v.to_str() == Some(std::process::id().to_string().as_str())),
|
||||
"the hermes update child claims the same marker we hold; without our pid \
|
||||
it refuses its own parent's lock and every GUI update dead-ends on exit 2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_probe_paths_include_desktop_app_payload() {
|
||||
let root = Path::new("/x/hermes-agent");
|
||||
|
|
|
|||
|
|
@ -5155,6 +5155,20 @@ function sendClosePreviewRequested() {
|
|||
webContents.send('hermes:close-preview-requested')
|
||||
}
|
||||
|
||||
function sendOpenFolderRequested() {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const webContents = mainWindow.webContents
|
||||
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
webContents.send('hermes:open-folder-requested')
|
||||
}
|
||||
|
||||
// Tell the renderer the machine just woke. Sleep silently drops the
|
||||
// renderer's WebSocket to the local backend; the renderer reconnects on this
|
||||
// signal so the chat composer doesn't stay stuck on "Starting Hermes...".
|
||||
|
|
@ -5272,6 +5286,10 @@ function buildApplicationMenu() {
|
|||
// a menu accelerator would fight the rebind panel and (on macOS) be
|
||||
// swallowed before the renderer sees it. Here purely for discoverability.
|
||||
{ click: () => createInstanceWindow(), label: 'New Window' },
|
||||
// Same no-accelerator rationale: ⌘O is the rebindable renderer keybind
|
||||
// (workspace.openFolder). Clicking runs the same open-folder-as-project
|
||||
// flow through the renderer.
|
||||
{ click: () => sendOpenFolderRequested(), label: 'Open Folder…' },
|
||||
{ type: 'separator' },
|
||||
IS_MAC
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -212,6 +212,12 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
|
||||
return () => ipcRenderer.removeListener('hermes:close-preview-requested', listener)
|
||||
},
|
||||
onOpenFolderRequested: callback => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('hermes:open-folder-requested', listener)
|
||||
|
||||
return () => ipcRenderer.removeListener('hermes:open-folder-requested', listener)
|
||||
},
|
||||
onOpenUpdatesRequested: callback => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('hermes:open-updates', listener)
|
||||
|
|
|
|||
146
apps/desktop/scripts/probe-command-palette.mjs
Normal file
146
apps/desktop/scripts/probe-command-palette.mjs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// ⌘K open latency, measured in-page (no CDP round-trip in the number).
|
||||
//
|
||||
// node scripts/probe-command-palette.mjs [--port 9222] [--rounds 8]
|
||||
//
|
||||
// Reports, per round, the time from the keydown the app actually receives to:
|
||||
// frame_ms — the dialog frame + input in the DOM and painted (what "instant"
|
||||
// means: the overlay owes you a frame immediately)
|
||||
// rows_ms — the row list painted (may lag frame_ms; rows are deferred)
|
||||
// plus any long tasks in the window, so a slow open is attributable.
|
||||
import { CDP, sleep } from './perf/lib/cdp.mjs'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const flag = name => {
|
||||
const i = args.indexOf(`--${name}`)
|
||||
|
||||
return i >= 0 ? args[i + 1] : undefined
|
||||
}
|
||||
|
||||
const port = Number(flag('port') ?? 9222)
|
||||
const rounds = Number(flag('rounds') ?? 8)
|
||||
|
||||
const cdp = await CDP.connect({ port })
|
||||
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const INSTALL = `
|
||||
(() => {
|
||||
if (window.__CMDK__) window.__CMDK__.stop()
|
||||
|
||||
const state = { t0: null, frame: null, rows: 0, rowsAt: null, tasks: [], armed: false }
|
||||
|
||||
// Time from the keydown the APP receives — excludes CDP transport, so the
|
||||
// number is what a user's finger actually experiences.
|
||||
const onKey = e => {
|
||||
if (state.armed && (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
state.t0 = performance.now()
|
||||
state.armed = false
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
|
||||
const obs = new MutationObserver(() => {
|
||||
if (state.t0 === null) return
|
||||
if (state.frame === null && document.querySelector('[cmdk-input]')) {
|
||||
state.frame = performance.now() - state.t0
|
||||
}
|
||||
const n = document.querySelectorAll('[cmdk-item]').length
|
||||
if (n > state.rows) { state.rows = n; state.rowsAt = performance.now() - state.t0 }
|
||||
})
|
||||
|
||||
obs.observe(document.body, { childList: true, subtree: true })
|
||||
|
||||
const po = new PerformanceObserver(list => {
|
||||
for (const e of list.getEntries()) state.tasks.push({ start: e.startTime, dur: Math.round(e.duration) })
|
||||
})
|
||||
|
||||
try { po.observe({ entryTypes: ['longtask'] }) } catch {}
|
||||
|
||||
window.__CMDK__ = {
|
||||
arm: () => { state.t0 = null; state.frame = null; state.rows = 0; state.rowsAt = null; state.tasks = []; state.armed = true },
|
||||
read: () => ({
|
||||
frame_ms: state.frame === null ? -1 : Math.round(state.frame),
|
||||
rows_ms: state.rowsAt === null ? -1 : Math.round(state.rowsAt),
|
||||
rows: state.rows,
|
||||
longtask_ms: state.t0 === null ? 0 : state.tasks.filter(t => t.start >= state.t0).reduce((s, t) => s + t.dur, 0)
|
||||
}),
|
||||
stop: () => { window.removeEventListener('keydown', onKey, true); obs.disconnect(); po.disconnect() }
|
||||
}
|
||||
|
||||
return true
|
||||
})()
|
||||
`
|
||||
|
||||
// Settle: frame painted AND rows stopped growing for two frames.
|
||||
const WAIT = `
|
||||
new Promise(resolve => {
|
||||
let stable = 0
|
||||
let last = -1
|
||||
const started = performance.now()
|
||||
const tick = () => {
|
||||
const r = window.__CMDK__.read()
|
||||
if (r.frame_ms >= 0 && r.rows === last && r.rows > 0) {
|
||||
if (++stable >= 2) { resolve(r); return }
|
||||
} else { stable = 0 }
|
||||
last = r.rows
|
||||
if (performance.now() - started > 8000) { resolve(window.__CMDK__.read()); return }
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
})
|
||||
`
|
||||
|
||||
const key = async type =>
|
||||
cdp.send('Input.dispatchKeyEvent', {
|
||||
type,
|
||||
key: 'k',
|
||||
code: 'KeyK',
|
||||
windowsVirtualKeyCode: 75,
|
||||
nativeVirtualKeyCode: 75,
|
||||
modifiers: 4
|
||||
})
|
||||
|
||||
const esc = async () => {
|
||||
for (const type of ['keyDown', 'keyUp']) {
|
||||
await cdp.send('Input.dispatchKeyEvent', { type, key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 })
|
||||
}
|
||||
|
||||
await sleep(400)
|
||||
}
|
||||
|
||||
await cdp.eval(INSTALL)
|
||||
await esc()
|
||||
|
||||
const samples = []
|
||||
|
||||
for (let i = 0; i < rounds; i++) {
|
||||
await sleep(250)
|
||||
await cdp.eval('window.__CMDK__.arm()')
|
||||
await key('rawKeyDown')
|
||||
await key('keyUp')
|
||||
const r = await cdp.eval(WAIT)
|
||||
samples.push(r)
|
||||
console.log(`round ${i}:`, r)
|
||||
await esc()
|
||||
}
|
||||
|
||||
await cdp.eval('window.__CMDK__.stop()')
|
||||
|
||||
const stat = k => {
|
||||
const v = samples.map(s => s[k]).filter(n => n >= 0).sort((a, b) => a - b)
|
||||
|
||||
if (!v.length) return null
|
||||
|
||||
return {
|
||||
min: v[0],
|
||||
median: v[Math.floor(v.length / 2)],
|
||||
max: v[v.length - 1]
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nkeydown → dialog frame painted (ms):', stat('frame_ms'))
|
||||
console.log('keydown → rows painted (ms):', stat('rows_ms'))
|
||||
console.log('long-task time in window (ms):', stat('longtask_ms'))
|
||||
|
||||
cdp.close()
|
||||
|
|
@ -121,7 +121,7 @@ export function useComposerDraft({
|
|||
const editor = editorRef.current
|
||||
|
||||
if (editor) {
|
||||
renderComposerContents(editor, next)
|
||||
renderComposerContents(editor, next, { trailingCommitted: true })
|
||||
placeCaretEnd(editor)
|
||||
}
|
||||
|
||||
|
|
@ -265,7 +265,7 @@ export function useComposerDraft({
|
|||
const editor = editorRef.current
|
||||
|
||||
if (editor && document.activeElement !== editor && composerPlainText(editor) !== text) {
|
||||
renderComposerContents(editor, text)
|
||||
renderComposerContents(editor, text, { trailingCommitted: true })
|
||||
}
|
||||
|
||||
if (isBrowsingHistory(sessionIdRef.current) || queueEditRef.current) {
|
||||
|
|
|
|||
|
|
@ -1043,9 +1043,7 @@ export function ChatBar({
|
|||
<div
|
||||
className={cn(
|
||||
'z-30 flex flex-col',
|
||||
poppedOut
|
||||
? 'fixed max-w-[calc(100vw-1.5rem)]'
|
||||
: 'absolute bottom-0 left-1/2 max-w-full -translate-x-1/2'
|
||||
poppedOut ? 'fixed max-w-[calc(100vw-1.5rem)]' : 'absolute bottom-0 left-1/2 max-w-full -translate-x-1/2'
|
||||
)}
|
||||
data-popped-out={poppedOut ? '' : undefined}
|
||||
data-slot="composer-dock"
|
||||
|
|
@ -1131,126 +1129,126 @@ export function ChatBar({
|
|||
}}
|
||||
ref={composerRef}
|
||||
>
|
||||
{isHelpHint && <HelpHint />}
|
||||
{trigger && !argStageEmpty && (
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={triggerActive}
|
||||
items={triggerItems}
|
||||
kind={trigger.kind}
|
||||
loading={triggerLoading}
|
||||
onHover={setTriggerActive}
|
||||
onPick={replaceTriggerWithChip}
|
||||
/>
|
||||
)}
|
||||
{!poppedOut && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 rounded-[inherit]"
|
||||
style={{ background: COMPOSER_FADE_BACKGROUND }}
|
||||
/>
|
||||
)}
|
||||
{/* Drag region: covers the transparent grab margin around the surface.
|
||||
{isHelpHint && <HelpHint />}
|
||||
{trigger && !argStageEmpty && (
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={triggerActive}
|
||||
items={triggerItems}
|
||||
kind={trigger.kind}
|
||||
loading={triggerLoading}
|
||||
onHover={setTriggerActive}
|
||||
onPick={replaceTriggerWithChip}
|
||||
/>
|
||||
)}
|
||||
{!poppedOut && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 rounded-[inherit]"
|
||||
style={{ background: COMPOSER_FADE_BACKGROUND }}
|
||||
/>
|
||||
)}
|
||||
{/* Drag region: covers the transparent grab margin around the surface.
|
||||
The surface sits on top (z-4) so only the exposed ring receives this
|
||||
element's hover/cursor — grab cursor + a diagonal hatch (/////)
|
||||
appear when you hover the draggable margin, never over the input.
|
||||
The hatch pattern + opacity ladder live in styles.css. */}
|
||||
{popoutAllowed && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn('pointer-events-auto absolute inset-0', dragging ? 'cursor-grabbing' : 'cursor-grab')}
|
||||
data-dragging={dragging ? '' : undefined}
|
||||
data-slot="composer-drag-region"
|
||||
onDoubleClick={handleComposerToggle}
|
||||
/>
|
||||
)}
|
||||
<div className="relative w-full rounded-[inherit]">
|
||||
<div
|
||||
className={cn(
|
||||
'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
|
||||
)}
|
||||
data-slot="composer-surface"
|
||||
ref={composerSurfaceRef}
|
||||
>
|
||||
{popoutAllowed && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
|
||||
composerFill,
|
||||
composerSurfaceGlass
|
||||
)}
|
||||
/>
|
||||
<CodingStatusRow
|
||||
onBranchOff={handleBranchOff}
|
||||
onConvertBranch={handleConvertBranch}
|
||||
onListBranches={handleListBranches}
|
||||
onOpen={toggleReview}
|
||||
onOpenWorktree={openInWorktree}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
repoPath={cwd}
|
||||
className={cn('pointer-events-auto absolute inset-0', dragging ? 'cursor-grabbing' : 'cursor-grab')}
|
||||
data-dragging={dragging ? '' : undefined}
|
||||
data-slot="composer-drag-region"
|
||||
onDoubleClick={handleComposerToggle}
|
||||
/>
|
||||
)}
|
||||
<div className="relative w-full rounded-[inherit]">
|
||||
<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',
|
||||
scrolledUp
|
||||
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer-surface:opacity-100'
|
||||
: 'opacity-100'
|
||||
'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
|
||||
)}
|
||||
data-slot="composer-fade"
|
||||
data-slot="composer-surface"
|
||||
ref={composerSurfaceRef}
|
||||
>
|
||||
{/* Contribution seams: banners above, a row below, inline
|
||||
additions beside the "+" menu and before the controls.
|
||||
All four render nothing until something contributes. */}
|
||||
<ContribSlot area={COMPOSER_AREAS.top} />
|
||||
<VoiceActivity state={voiceActivityState} />
|
||||
<VoicePlaybackActivity />
|
||||
{queueEdit && editingQueuedPrompt && (
|
||||
<div className="flex items-center justify-between gap-2 rounded-lg border border-[color-mix(in_srgb,var(--dt-composer-ring)_32%,transparent)] bg-accent/18 px-2 py-1">
|
||||
<div className="min-w-0 text-[0.7rem] text-muted-foreground/88">
|
||||
{t.composer.editingQueuedInComposer}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
className="h-6 rounded-md px-2 text-[0.68rem]"
|
||||
onClick={() => exitQueuedEdit('cancel')}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
className="h-6 rounded-md px-2 text-[0.68rem]"
|
||||
onClick={() => exitQueuedEdit('save')}
|
||||
type="button"
|
||||
>
|
||||
{t.common.save}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{attachments.length > 0 && <AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />}
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
|
||||
composerFill,
|
||||
composerSurfaceGlass
|
||||
)}
|
||||
/>
|
||||
<CodingStatusRow
|
||||
onBranchOff={handleBranchOff}
|
||||
onConvertBranch={handleConvertBranch}
|
||||
onListBranches={handleListBranches}
|
||||
onOpen={toggleReview}
|
||||
onOpenWorktree={openInWorktree}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
repoPath={cwd}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'grid w-full',
|
||||
stacked
|
||||
? 'grid-cols-[auto_1fr] gap-(--composer-row-gap) [grid-template-areas:"input_input"_"menu_controls"]'
|
||||
: 'grid-cols-[auto_1fr_auto] items-center gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
|
||||
'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',
|
||||
scrolledUp
|
||||
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer-surface:opacity-100'
|
||||
: 'opacity-100'
|
||||
)}
|
||||
data-slot="composer-fade"
|
||||
>
|
||||
<div className="flex translate-y-[3px] items-start gap-(--composer-control-gap) self-start [grid-area:menu]">
|
||||
{contextMenu}
|
||||
<ContribSlot area={COMPOSER_AREAS.leading} />
|
||||
</div>
|
||||
<div className="min-w-0 [grid-area:input]">{input}</div>
|
||||
<div className="flex items-center justify-end gap-(--composer-control-gap) [grid-area:controls]">
|
||||
<ContribSlot area={COMPOSER_AREAS.actions} />
|
||||
{controls}
|
||||
{/* Contribution seams: banners above, a row below, inline
|
||||
additions beside the "+" menu and before the controls.
|
||||
All four render nothing until something contributes. */}
|
||||
<ContribSlot area={COMPOSER_AREAS.top} />
|
||||
<VoiceActivity state={voiceActivityState} />
|
||||
<VoicePlaybackActivity />
|
||||
{queueEdit && editingQueuedPrompt && (
|
||||
<div className="flex items-center justify-between gap-2 rounded-lg border border-[color-mix(in_srgb,var(--dt-composer-ring)_32%,transparent)] bg-accent/18 px-2 py-1">
|
||||
<div className="min-w-0 text-[0.7rem] text-muted-foreground/88">
|
||||
{t.composer.editingQueuedInComposer}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
className="h-6 rounded-md px-2 text-[0.68rem]"
|
||||
onClick={() => exitQueuedEdit('cancel')}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
className="h-6 rounded-md px-2 text-[0.68rem]"
|
||||
onClick={() => exitQueuedEdit('save')}
|
||||
type="button"
|
||||
>
|
||||
{t.common.save}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{attachments.length > 0 && <AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />}
|
||||
<div
|
||||
className={cn(
|
||||
'grid w-full',
|
||||
stacked
|
||||
? 'grid-cols-[auto_1fr] gap-(--composer-row-gap) [grid-template-areas:"input_input"_"menu_controls"]'
|
||||
: 'grid-cols-[auto_1fr_auto] items-center gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
|
||||
)}
|
||||
>
|
||||
<div className="flex translate-y-[3px] items-start gap-(--composer-control-gap) self-start [grid-area:menu]">
|
||||
{contextMenu}
|
||||
<ContribSlot area={COMPOSER_AREAS.leading} />
|
||||
</div>
|
||||
<div className="min-w-0 [grid-area:input]">{input}</div>
|
||||
<div className="flex items-center justify-end gap-(--composer-control-gap) [grid-area:controls]">
|
||||
<ContribSlot area={COMPOSER_AREAS.actions} />
|
||||
{controls}
|
||||
</div>
|
||||
</div>
|
||||
<ContribSlot area={COMPOSER_AREAS.bottom} />
|
||||
</div>
|
||||
<ContribSlot area={COMPOSER_AREAS.bottom} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
{/* Underside: chrome-free strip BELOW the composer. Outside the root
|
||||
for the same reason as the micro actions — it must not fall inside
|
||||
|
|
|
|||
|
|
@ -230,6 +230,82 @@ describe('insertComposerContentsAtCaret', () => {
|
|||
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
// A directive typed by hand chips; the same directive pasted has to chip too,
|
||||
// or copy/pasting a prompt silently drops every command in it.
|
||||
it('chips a pasted slash command, including one that ends the paste', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
document.body.append(editor)
|
||||
caretIn(editor)
|
||||
|
||||
insertComposerContentsAtCaret(editor, '/some-skill')
|
||||
|
||||
expect(editor.querySelector('[data-slash-kind]')?.getAttribute('data-ref-text')).toBe('/some-skill')
|
||||
// Committed pills carry the trailing space the typed path appends, so a
|
||||
// later full re-render doesn't read the token as half-typed.
|
||||
expect(composerPlainText(editor)).toBe('/some-skill ')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('chips a skill named mid-paste alongside a ref', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
document.body.append(editor)
|
||||
caretIn(editor)
|
||||
|
||||
insertComposerContentsAtCaret(editor, 'clean @file:`a.ts` with /some-skill then ship')
|
||||
|
||||
expect(editor.querySelectorAll('[data-slash-kind]').length).toBe(1)
|
||||
expect(editor.querySelectorAll('[data-ref-kind="file"]').length).toBe(1)
|
||||
expect(composerPlainText(editor)).toBe('clean @file:`a.ts` with /some-skill then ship')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('leaves a pasted path alone — /usr/local is not a command', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
document.body.append(editor)
|
||||
caretIn(editor)
|
||||
|
||||
insertComposerContentsAtCaret(editor, 'see /usr/local/bin and /goal ship it')
|
||||
|
||||
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
|
||||
expect(composerPlainText(editor)).toBe('see /usr/local/bin and /goal ship it')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('does not chip a command pasted against a word — foo/clean is not a command', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.textContent = 'foo'
|
||||
document.body.append(editor)
|
||||
caretIn(editor)
|
||||
|
||||
insertComposerContentsAtCaret(editor, '/some-skill')
|
||||
|
||||
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
|
||||
expect(composerPlainText(editor)).toBe('foo/some-skill')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('chips a command pasted right after an existing chip', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.append(refChipElement('file', '`a.ts`'))
|
||||
document.body.append(editor)
|
||||
caretIn(editor)
|
||||
|
||||
insertComposerContentsAtCaret(editor, '/some-skill')
|
||||
|
||||
expect(editor.querySelector('[data-slash-kind]')).not.toBeNull()
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe('replaceBeforeCaret', () => {
|
||||
|
|
|
|||
|
|
@ -16,22 +16,13 @@ import {
|
|||
type SlashChipKind,
|
||||
slashIconElement
|
||||
} from '@/components/assistant-ui/directive-text'
|
||||
import {
|
||||
desktopSlashCommandArgumentMode,
|
||||
isDesktopSlashCommand,
|
||||
resolveDesktopCommand
|
||||
} from '@/lib/desktop-slash-commands'
|
||||
|
||||
import { slashCommandMatches, type SlashCommandScanOptions } from './slash-refs'
|
||||
|
||||
export const RICH_INPUT_SLOT = 'composer-rich-input'
|
||||
|
||||
export const REF_RE = /@(file|folder|url|image|tool|line|terminal|session):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
|
||||
|
||||
/** A committed leading slash command: `/name` followed by whitespace. The
|
||||
* whitespace requirement is what separates a committed command (chips always
|
||||
* serialize with their auto-inserted trailing space) from one still being
|
||||
* typed, which must stay editable text. */
|
||||
const LEADING_SLASH_COMMAND_RE = /^\/[a-zA-Z][\w-]*(?=\s)/
|
||||
|
||||
const ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
||||
|
||||
export function escapeHtml(value: string) {
|
||||
|
|
@ -123,42 +114,59 @@ function appendTextWithBreaks(target: DocumentFragment | HTMLElement, text: stri
|
|||
})
|
||||
}
|
||||
|
||||
export function appendComposerContents(target: DocumentFragment | HTMLElement, text: string) {
|
||||
let cursor = 0
|
||||
|
||||
/** Every span of `text` that renders as a chip, in source order. */
|
||||
function chipSpans(text: string, options: SlashCommandScanOptions) {
|
||||
REF_RE.lastIndex = 0
|
||||
|
||||
for (const match of text.matchAll(REF_RE)) {
|
||||
const index = match.index ?? 0
|
||||
appendTextWithBreaks(target, text.slice(cursor, index))
|
||||
target.append(refChipElement(match[1] || 'file', match[2] || ''))
|
||||
cursor = index + match[0].length
|
||||
const refs = Array.from(text.matchAll(REF_RE)).map(match => {
|
||||
const start = match.index ?? 0
|
||||
|
||||
return { end: start + match[0].length, node: () => refChipElement(match[1] || 'file', match[2] || ''), start }
|
||||
})
|
||||
|
||||
const commands = slashCommandMatches(text, options).map(match => ({
|
||||
end: match.end,
|
||||
node: () => slashChipElement(match.command, match.kind),
|
||||
start: match.start
|
||||
}))
|
||||
|
||||
return [...refs, ...commands].sort((a, b) => a.start - b.start)
|
||||
}
|
||||
|
||||
/** Build the chip/text DOM for `text`. Directives hydrate back to their pills —
|
||||
* `@kind:value` refs and `/command` invocations both — so text that arrives
|
||||
* whole (a paste, a restored draft, an undo step, a rebuilt line) carries the
|
||||
* same chips the typed path would have committed. */
|
||||
export function appendComposerContents(
|
||||
target: DocumentFragment | HTMLElement,
|
||||
text: string,
|
||||
options: SlashCommandScanOptions = {}
|
||||
) {
|
||||
let cursor = 0
|
||||
|
||||
for (const span of chipSpans(text, options)) {
|
||||
// A `@` ref wins an overlap: a command token can't contain an `@`, so the
|
||||
// only way spans collide is a slash inside a quoted ref value
|
||||
// (`` @url:`a /clean` ``), which belongs to that value.
|
||||
if (span.start < cursor) {
|
||||
continue
|
||||
}
|
||||
|
||||
appendTextWithBreaks(target, text.slice(cursor, span.start))
|
||||
target.append(span.node())
|
||||
cursor = span.end
|
||||
}
|
||||
|
||||
appendTextWithBreaks(target, text.slice(cursor))
|
||||
}
|
||||
|
||||
export function renderComposerContents(target: HTMLElement, text: string) {
|
||||
export function renderComposerContents(target: HTMLElement, text: string, options?: SlashCommandScanOptions) {
|
||||
target.replaceChildren()
|
||||
|
||||
// A leading `/command` hydrates back to its pill — parity with REF_RE for
|
||||
// `@` refs, so a full re-render from serialized text (draft restore, undo,
|
||||
// the trigger commit fallback) doesn't demote a committed command chip to
|
||||
// plain text. Only commands with NO argument stage qualify (skills, quick
|
||||
// commands, no-arg built-ins): their committed pill is exactly the bare
|
||||
// `/name`, so the boundary is unambiguous. Arg-taking commands (`/goal ship
|
||||
// it`, `/personality alice`) stay text — their tail may be prose that was
|
||||
// never committed. The trailing whitespace is load-bearing too: a committed
|
||||
// pill always serializes with its auto-inserted space, while a half-typed
|
||||
// `/wor` must stay editable text.
|
||||
const command = LEADING_SLASH_COMMAND_RE.exec(text)?.[0]
|
||||
|
||||
if (command && isDesktopSlashCommand(command) && desktopSlashCommandArgumentMode(command) === null) {
|
||||
target.append(slashChipElement(command, resolveDesktopCommand(command) ? 'command' : 'skill'))
|
||||
text = text.slice(command.length)
|
||||
}
|
||||
|
||||
appendComposerContents(target, text)
|
||||
// Defaults to live editing, where a token ending the text is still being
|
||||
// typed (`/wor`) and must stay editable. Callers repainting inert text (a
|
||||
// restored draft, a sent message opened for edit) pass `trailingCommitted`.
|
||||
appendComposerContents(target, text, options)
|
||||
}
|
||||
|
||||
/** Caret range when the selection lives inside `editor`; else null. */
|
||||
|
|
@ -173,20 +181,79 @@ function composerSelectionRange(editor: HTMLElement) {
|
|||
return { range, selection }
|
||||
}
|
||||
|
||||
/** Insert text at the caret (replacing any selection), with any `@kind:value`
|
||||
* directives in it landing as chips. Pastes use this instead of
|
||||
* `execCommand('insertText')` — Chromium's editing pipeline is ~O(n²) on large
|
||||
* multiline blobs. */
|
||||
/** Serialized text from the editor's start up to (`container`, `offset`).
|
||||
*
|
||||
* Chips are ATOMIC here: each contributes an object-replacement placeholder
|
||||
* rather than leaking its label text, and a <br> contributes a newline. That
|
||||
* makes a chip edge read as a token boundary, which is what both trigger
|
||||
* detection and directive recognition need. */
|
||||
export function serializeTextBefore(editor: HTMLElement, container: Node, offset: number): string {
|
||||
const probe = document.createRange()
|
||||
|
||||
probe.selectNodeContents(editor)
|
||||
probe.setEnd(container, offset)
|
||||
|
||||
const scratch = document.createElement('div')
|
||||
|
||||
scratch.append(probe.cloneContents())
|
||||
|
||||
for (const chip of scratch.querySelectorAll('[data-ref-text]')) {
|
||||
chip.replaceWith('\uFFFC')
|
||||
}
|
||||
|
||||
for (const br of scratch.querySelectorAll('br')) {
|
||||
br.replaceWith('\n')
|
||||
}
|
||||
|
||||
return scratch.textContent ?? ''
|
||||
}
|
||||
|
||||
/** True when the insertion point starts a token — the editor's start, or after
|
||||
* whitespace or a chip. `foo` + a pasted `/clean` is `foo/clean`, not a
|
||||
* command; `foo ` + the same paste is. */
|
||||
function atTokenBoundary(editor: HTMLElement, range: Range | null): boolean {
|
||||
// No caret means the insert lands at the end, so the question is about the
|
||||
// editor's last character either way.
|
||||
const before = range
|
||||
? serializeTextBefore(editor, range.startContainer, range.startOffset)
|
||||
: serializeTextBefore(editor, editor, editor.childNodes.length)
|
||||
|
||||
const last = before.slice(-1)
|
||||
|
||||
return !last || /[\s\uFFFC]/.test(last)
|
||||
}
|
||||
|
||||
/** Insert text at the caret (replacing any selection), with any directives in
|
||||
* it landing as chips. Pastes use this instead of `execCommand('insertText')`
|
||||
* — Chromium's editing pipeline is ~O(n²) on large multiline blobs.
|
||||
*
|
||||
* The text arrives whole rather than typed, so a `/command` ending it is
|
||||
* complete rather than half-written and chips like the rest. */
|
||||
export function insertComposerContentsAtCaret(editor: HTMLElement, text: string) {
|
||||
const hit = composerSelectionRange(editor)
|
||||
const fragment = document.createDocumentFragment()
|
||||
|
||||
appendComposerContents(fragment, text)
|
||||
// Before measuring the boundary — a replaced selection puts the insertion
|
||||
// point where the selection started, not where it ended.
|
||||
if (hit) {
|
||||
hit.range.deleteContents()
|
||||
}
|
||||
|
||||
appendComposerContents(fragment, text, {
|
||||
boundaryBefore: atTokenBoundary(editor, hit?.range ?? null),
|
||||
trailingCommitted: true
|
||||
})
|
||||
|
||||
// A slash pill ending the insert gets the trailing space the typed commit
|
||||
// path appends, or the next full re-render reads it as a half-typed token
|
||||
// and demotes it. `@` refs need no marker — REF_RE re-chips them either way.
|
||||
if ((fragment.lastChild as HTMLElement | null)?.dataset?.slashKind) {
|
||||
fragment.append(document.createTextNode(' '))
|
||||
}
|
||||
|
||||
const tail = fragment.lastChild
|
||||
|
||||
if (hit) {
|
||||
hit.range.deleteContents()
|
||||
hit.range.insertNode(fragment)
|
||||
} else {
|
||||
editor.append(fragment)
|
||||
|
|
|
|||
45
apps/desktop/src/app/chat/composer/slash-refs.test.ts
Normal file
45
apps/desktop/src/app/chat/composer/slash-refs.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { slashCommandMatches } from './slash-refs'
|
||||
|
||||
const commands = (text: string, options?: Parameters<typeof slashCommandMatches>[1]) =>
|
||||
slashCommandMatches(text, options).map(match => `${match.kind}:${match.command}`)
|
||||
|
||||
describe('slashCommandMatches', () => {
|
||||
it('recognizes a leading command and a skill named mid-prose', () => {
|
||||
expect(commands('/some-skill clean this with /other-skill please')).toEqual([
|
||||
'skill:/some-skill',
|
||||
'skill:/other-skill'
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves a path alone — /usr/local/bin is not a command', () => {
|
||||
expect(commands('see /usr/local/bin ')).toEqual([])
|
||||
})
|
||||
|
||||
it('holds a trailing token as still-typed unless the text is inert', () => {
|
||||
expect(commands('/some-skill')).toEqual([])
|
||||
expect(commands('/some-skill', { trailingCommitted: true })).toEqual(['skill:/some-skill'])
|
||||
})
|
||||
|
||||
it('leaves an arg-taking command as text — its tail may be prose', () => {
|
||||
expect(commands('/goal ship the redesign')).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves a command with no desktop surface as text', () => {
|
||||
expect(commands('/exit now')).toEqual([])
|
||||
})
|
||||
|
||||
it('offers a built-in only as an invocation, never mid-message', () => {
|
||||
// Mirrors what the popover offers: `/new` acts on the app, so it means
|
||||
// nothing dropped into a sentence, while a skill reads as "handle this
|
||||
// part with X".
|
||||
expect(commands('/new ')).toEqual(['command:/new'])
|
||||
expect(commands('start over with /new ')).toEqual([])
|
||||
expect(commands('start over with /some-skill ')).toEqual(['skill:/some-skill'])
|
||||
})
|
||||
|
||||
it('disqualifies a leading token when the text lands mid-word', () => {
|
||||
expect(commands('/some-skill ', { boundaryBefore: false })).toEqual([])
|
||||
})
|
||||
})
|
||||
105
apps/desktop/src/app/chat/composer/slash-refs.ts
Normal file
105
apps/desktop/src/app/chat/composer/slash-refs.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* Slash-command recognition for text the composer did not watch being typed —
|
||||
* a paste, a restored draft, an undo step, a rebuilt line.
|
||||
*
|
||||
* The typed path chips a command as it's picked or accepted, so the composer
|
||||
* agrees with what the sent message renders (`SLASH_SKILL_RE` in
|
||||
* directive-text). Text that arrives whole never passed through that path, so
|
||||
* it needs the same commands recognized in place — on exactly the terms the
|
||||
* typed path would have used, or hydration invents pills the popover would
|
||||
* never have committed.
|
||||
*/
|
||||
import type { SlashChipKind } from '@/components/assistant-ui/directive-text'
|
||||
import {
|
||||
desktopSlashCommandArgumentMode,
|
||||
isDesktopSlashCommand,
|
||||
resolveDesktopCommand
|
||||
} from '@/lib/desktop-slash-commands'
|
||||
|
||||
// A command token starts a word and doesn't continue into a path: `/usr/local`
|
||||
// is a path, not a `/usr` command. Same shape the sent message uses to decide
|
||||
// what renders as a pill, so the composer and the transcript agree.
|
||||
const SLASH_COMMAND_RE = /(?<=^|\s)\/([a-zA-Z][\w-]*)(?![\w-]*\/)/g
|
||||
|
||||
export interface SlashCommandMatch {
|
||||
/** The command with its leading slash, e.g. `/clean`. */
|
||||
command: string
|
||||
end: number
|
||||
kind: SlashChipKind
|
||||
start: number
|
||||
}
|
||||
|
||||
export interface SlashCommandScanOptions {
|
||||
/**
|
||||
* Whether the text is preceded by a token boundary. False when it's being
|
||||
* inserted mid-word (a paste landing against existing characters), which
|
||||
* disqualifies a token at index 0 — `foo/clean` is not a command. It also
|
||||
* makes that token mid-message rather than an invocation.
|
||||
*/
|
||||
boundaryBefore?: boolean
|
||||
/**
|
||||
* Whether a token ending the text counts as committed. True for inert text
|
||||
* (a paste, dropped content): nothing is being typed, so `/clean` at the end
|
||||
* is the whole command. False while editing live, where a trailing `/wor` is
|
||||
* a half-typed query the popover owns and must leave editable.
|
||||
*/
|
||||
trailingCommitted?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Only commands with NO argument stage chip: their committed pill is exactly
|
||||
* the bare `/name`, so the boundary is unambiguous. Arg-taking commands
|
||||
* (`/goal ship it`) stay text — their tail may be prose. Commands with no
|
||||
* desktop surface at all (`/exit`, `/config`) stay text too.
|
||||
*/
|
||||
function chippableKind(command: string): SlashChipKind | null {
|
||||
if (!isDesktopSlashCommand(command) || desktopSlashCommandArgumentMode(command) !== null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return resolveDesktopCommand(command) ? 'command' : 'skill'
|
||||
}
|
||||
|
||||
/** Every `/command` in `text` that should render as a pill, in source order. */
|
||||
export function slashCommandMatches(text: string, options: SlashCommandScanOptions = {}): SlashCommandMatch[] {
|
||||
const { boundaryBefore = true, trailingCommitted = false } = options
|
||||
|
||||
if (!text.includes('/')) {
|
||||
return []
|
||||
}
|
||||
|
||||
const matches: SlashCommandMatch[] = []
|
||||
|
||||
for (const match of text.matchAll(SLASH_COMMAND_RE)) {
|
||||
const start = match.index ?? 0
|
||||
const command = match[0]
|
||||
const end = start + command.length
|
||||
const after = text[end]
|
||||
|
||||
// A committed pill always carries its auto-inserted trailing space, which
|
||||
// is what separates it from a token still being typed.
|
||||
if (after === undefined ? !trailingCommitted : !/\s/.test(after)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only the FIRST token can be an invocation, and only when the text lands
|
||||
// on a token boundary — `foo` + a pasted `/clean` is `foo/clean`.
|
||||
const invocation = start === 0
|
||||
|
||||
if (invocation && !boundaryBefore) {
|
||||
continue
|
||||
}
|
||||
|
||||
const kind = chippableKind(command)
|
||||
|
||||
// Later tokens are references dropped into prose, where the popover offers
|
||||
// SKILLS alone — a built-in like `/new` acts on the app and means nothing
|
||||
// mid-sentence. Hydration has to agree, or pasted text grows pills typing
|
||||
// never would.
|
||||
if (kind && (invocation || kind === 'skill')) {
|
||||
matches.push({ command, end, kind, start })
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images'
|
||||
import { $reactionsEnabled } from '@/store/reactions-enabled'
|
||||
|
||||
import { serializeTextBefore } from './rich-editor'
|
||||
|
||||
export interface TriggerState {
|
||||
/** True for a `/` typed mid-message — an inline skill/command reference in
|
||||
* prose rather than a command invocation. Arg completion doesn't apply. */
|
||||
|
|
@ -141,23 +143,7 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null {
|
|||
return null
|
||||
}
|
||||
|
||||
const before = range.cloneRange()
|
||||
before.selectNodeContents(editor)
|
||||
before.setEnd(range.startContainer, range.startOffset)
|
||||
|
||||
const scratch = document.createElement('div')
|
||||
|
||||
scratch.append(before.cloneContents())
|
||||
|
||||
for (const chip of scratch.querySelectorAll('[data-ref-text]')) {
|
||||
chip.replaceWith('\uFFFC')
|
||||
}
|
||||
|
||||
for (const br of scratch.querySelectorAll('br')) {
|
||||
br.replaceWith('\n')
|
||||
}
|
||||
|
||||
return scratch.textContent ?? ''
|
||||
return serializeTextBefore(editor, range.startContainer, range.startOffset)
|
||||
}
|
||||
|
||||
export function detectTrigger(textBefore: string): TriggerState | null {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { cn } from '@/lib/utils'
|
|||
|
||||
/** The muted slot beside a section label (loading glyph, status hint). */
|
||||
export function SidebarSectionMeta({ children }: { children: React.ReactNode }) {
|
||||
return <span className="text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{children}</span>
|
||||
return <span className="shrink-0 text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{children}</span>
|
||||
}
|
||||
|
||||
// ── Row geometry (session row is canonical — everything composes these) ─────
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ export function SidebarCronJobsSection({
|
|||
<SidebarGroup className="shrink-0 p-0 pb-1">
|
||||
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
|
||||
<button
|
||||
className="group/section-label flex w-fit items-center gap-1 bg-transparent text-left leading-none"
|
||||
className="group/section-label flex w-fit min-w-0 items-center gap-1 bg-transparent text-left leading-none"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ function LaneLabel({ label, title }: { label: string; title?: string }) {
|
|||
const tail = label.slice(label.length - tailLen)
|
||||
|
||||
return (
|
||||
<span className="flex min-w-0" title={title}>
|
||||
// overflow-hidden: the pinned tail is shrink-0, so at extreme narrow widths
|
||||
// it must clip inside the label rather than push the trailing icons out.
|
||||
<span className="flex min-w-0 overflow-hidden" title={title}>
|
||||
<span className="truncate">{head}</span>
|
||||
<span className="shrink-0 whitespace-pre">{tail}</span>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -64,7 +64,9 @@ function SidebarSectionHeader({
|
|||
<div className="group/section flex shrink-0 items-center justify-between gap-1 pb-1 pt-1.5">
|
||||
{collapsible ? (
|
||||
<button
|
||||
className="group/section-label flex w-fit items-center gap-1 bg-transparent text-left leading-none"
|
||||
// min-w-0 lets the label truncate at narrow sidebar widths instead of
|
||||
// pushing the header's trailing action icons out of view.
|
||||
className="group/section-label flex w-fit min-w-0 items-center gap-1 bg-transparent text-left leading-none"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
|
|
@ -75,7 +77,7 @@ function SidebarSectionHeader({
|
|||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex w-fit items-center gap-1 leading-none">{labelBody}</div>
|
||||
<div className="flex w-fit min-w-0 items-center gap-1 leading-none">{labelBody}</div>
|
||||
)}
|
||||
{action}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import {
|
||||
|
|
@ -14,6 +14,7 @@ import {
|
|||
HUD_TEXT
|
||||
} from '@/app/floating-hud'
|
||||
import { setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||
import { codiconIcon } from '@/components/ui/codicon'
|
||||
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { HighlightMatches } from '@/components/ui/highlight-matches'
|
||||
import { KbdCombo } from '@/components/ui/kbd'
|
||||
|
|
@ -68,7 +69,7 @@ import {
|
|||
} from '@/store/command-palette'
|
||||
import { $bindings } from '@/store/keybinds'
|
||||
import { openPetGenerate } from '@/store/pet-generate'
|
||||
import { requestStartWorkSession } from '@/store/projects'
|
||||
import { $projectTree, goToProject, openFolderAsProject, requestStartWorkSession } from '@/store/projects'
|
||||
import { $connection } from '@/store/session'
|
||||
import { runGatewayRestart } from '@/store/system-actions'
|
||||
import {
|
||||
|
|
@ -111,6 +112,8 @@ interface PaletteItem {
|
|||
action?: string
|
||||
/** Renders a trailing check: this row IS the current setting (theme, mode). */
|
||||
active?: boolean
|
||||
/** Static trailing combo hint for a modifier-variant select (e.g. `mod+enter`). */
|
||||
comboHint?: string
|
||||
/** Short note beside the label — state the row acts on (a version, a count). */
|
||||
detail?: string
|
||||
/** `state` when the row will change what `detail` says (a toggle's on/off). */
|
||||
|
|
@ -121,6 +124,8 @@ interface PaletteItem {
|
|||
keepOpen?: boolean
|
||||
keywords?: string[]
|
||||
label: string
|
||||
/** Label shown while ⌘/⌃ is held — previews the modifier-variant action. */
|
||||
modLabel?: string
|
||||
/**
|
||||
* When set, ⌘/⌃-select (or ⌘-Enter) opens a new tab and ⇧⌘-select pops a
|
||||
* window — matching sidebar session rows. Plain select stays in-place.
|
||||
|
|
@ -240,21 +245,92 @@ const rankGroups = (groups: PaletteGroup[], search: string): PaletteGroup[] => {
|
|||
// theme lists under both Light and Dark). The id suffix disambiguates.
|
||||
const paletteValue = (item: PaletteItem): string => `${item.label}\u0001${item.id}`
|
||||
|
||||
const EMPTY_GROUPS: PaletteGroup[] = []
|
||||
|
||||
// Backstop only. The palette normally retires on the content's real
|
||||
// `animationend`, so the CSS owns the close duration; this just guarantees the
|
||||
// body can't stay mounted forever somewhere animations never run (jsdom,
|
||||
// `animation: none`). Deliberately longer than any plausible exit so it never
|
||||
// races the real signal and truncates the fade.
|
||||
const EXIT_FALLBACK_MS = 1000
|
||||
|
||||
/**
|
||||
* The palette's row list, split out so an OPENING palette paints before it
|
||||
* renders rows. This component mounts with the portal, so `useDeferredValue`'s
|
||||
* initial value applies per open: the first commit is the frame + input
|
||||
* (instant), and the several-hundred-row list arrives in an interruptible
|
||||
* follow-up render. Opening ⌘K must never wait on building the list.
|
||||
*/
|
||||
const PaletteGroups = memo(function PaletteGroups({
|
||||
bindings,
|
||||
groups,
|
||||
modHeld,
|
||||
noResultsLabel,
|
||||
onSelectItem,
|
||||
onSelectMods,
|
||||
search
|
||||
}: {
|
||||
bindings: Record<string, string[]>
|
||||
groups: PaletteGroup[]
|
||||
modHeld: boolean
|
||||
noResultsLabel: string
|
||||
onSelectItem: (item: PaletteItem) => void
|
||||
onSelectMods: (event: { ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }) => void
|
||||
search: string
|
||||
}) {
|
||||
const deferred = useDeferredValue(groups, EMPTY_GROUPS)
|
||||
// While the rows are still catching up, an empty list means "not rendered
|
||||
// yet", not "nothing matched" — don't flash the empty state on open.
|
||||
const pending = deferred !== groups
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Filtering happens in rankGroups, so cmdk's own CommandEmpty
|
||||
(keyed to its internal filter count) would never fire. */}
|
||||
{deferred.length === 0 && !pending && (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">{noResultsLabel}</div>
|
||||
)}
|
||||
{deferred.map((group, index) => (
|
||||
<CommandGroup className={HUD_HEADING} heading={group.heading} key={group.heading ?? `palette-group-${index}`}>
|
||||
{group.items.map(item => (
|
||||
<PaletteRow
|
||||
bindings={bindings}
|
||||
item={item}
|
||||
key={item.id}
|
||||
modHeld={modHeld}
|
||||
onSelectItem={onSelectItem}
|
||||
onSelectMods={onSelectMods}
|
||||
search={search}
|
||||
/>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const PaletteRow = memo(function PaletteRow({
|
||||
bindings,
|
||||
item,
|
||||
modHeld,
|
||||
onSelectMods,
|
||||
onSelectItem,
|
||||
search
|
||||
}: {
|
||||
bindings: Record<string, string[]>
|
||||
item: PaletteItem
|
||||
modHeld: boolean
|
||||
onSelectMods: (event: { ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }) => void
|
||||
onSelectItem: (item: PaletteItem) => void
|
||||
search: string
|
||||
}) {
|
||||
const Icon = item.icon
|
||||
const combo = item.action ? bindings[item.action]?.[0] : undefined
|
||||
// The row's live keybind, else a static modifier-variant hint (⌘↵). One slot,
|
||||
// so every downstream `ml-auto` fallback below keeps working unchanged.
|
||||
const combo = (item.action ? bindings[item.action]?.[0] : undefined) ?? item.comboHint
|
||||
// While ⌘/⌃ is held, a row with a modifier variant previews it: the label
|
||||
// swaps to the variant's copy so Enter reads as what it will actually do.
|
||||
const modPreview = modHeld && Boolean(item.modLabel)
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
|
|
@ -265,15 +341,21 @@ const PaletteRow = memo(function PaletteRow({
|
|||
value={paletteValue(item)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
{/* Same per-term split as scoreItem's AND matcher, so the emphasis
|
||||
shows exactly which words earned the row its rank. */}
|
||||
<HighlightMatches query={search.split(/\s+/)} text={item.label} />
|
||||
<span className={cn('truncate', modPreview && 'text-muted-foreground/80')}>
|
||||
{modPreview ? (
|
||||
item.modLabel
|
||||
) : (
|
||||
/* Same per-term split as scoreItem's AND matcher, so the emphasis
|
||||
shows exactly which words earned the row its rank. */
|
||||
<HighlightMatches query={search.split(/\s+/)} text={item.label} />
|
||||
)}
|
||||
</span>
|
||||
{item.detail && (
|
||||
<span className={cn(HUD_NOTE, HUD_NOTE_VARIANT[item.detailVariant ?? 'muted'])}>{item.detail}</span>
|
||||
)}
|
||||
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
|
||||
{combo && (
|
||||
<KbdCombo className={cn('ml-auto', modPreview ? 'opacity-90' : 'opacity-55')} combo={combo} size="sm" />
|
||||
)}
|
||||
{item.to && <ChevronRight className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')} />}
|
||||
{item.active && <Check className={cn('size-3.5 shrink-0 text-primary', !combo && !item.to && 'ml-auto')} />}
|
||||
</CommandItem>
|
||||
|
|
@ -284,6 +366,12 @@ const PaletteRow = memo(function PaletteRow({
|
|||
// "Go to session ‹id›" jump for ids that aren't in the recent-200 list.
|
||||
const SESSION_ID_RE = /^\d{8}_\d{6}_[a-f0-9]{6}$/
|
||||
|
||||
// A typed/pasted folder path: absolute (`/…`) or a Windows drive (`C:\…`).
|
||||
// Deliberately NOT `~/…`: the upsert's membership check (projectIdForCwd)
|
||||
// compares literal strings against the tree's absolute paths, so an unexpanded
|
||||
// home path would always miss and double-create.
|
||||
const FOLDER_PATH_RE = /^(\/|[A-Za-z]:[/\\]).+/
|
||||
|
||||
type SessionRow = Awaited<ReturnType<typeof listAllProfileSessions>>['sessions'][number]
|
||||
|
||||
const toSessionEntry = (session: SessionRow): SessionEntry => ({
|
||||
|
|
@ -365,12 +453,72 @@ function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean {
|
|||
return target === 'dark' ? luminance(background) <= 0.5 : luminance(background) > 0.5
|
||||
}
|
||||
|
||||
/**
|
||||
* ⌘K is an overlay that is stateful to itself: pressing it must open a frame
|
||||
* immediately, and must not be held up by whatever else the shell is doing. So
|
||||
* the mounted cost of a CLOSED palette is one store subscription and nothing
|
||||
* else.
|
||||
*
|
||||
* Everything expensive — a dozen store subscriptions (connection, update
|
||||
* status/apply, keybinds, worktrees, projects, theme, i18n), three server
|
||||
* queries, and the group builders that assemble a few hundred rows — lives in
|
||||
* `CommandPaletteBody`, which only exists while the palette is on screen.
|
||||
* Before this split those hooks ran on every render of the always-mounted
|
||||
* component: an in-flight update rewrote `$updateApply` per progress line and
|
||||
* rebuilt the entire row set each time, for a surface nobody could see.
|
||||
*
|
||||
* `mounted` lags `open` by the close animation rather than tracking it exactly.
|
||||
* Unmounting the body the instant `open` flips false would rip the content out
|
||||
* of the tree before Radix could play `data-[state=closed]`, so the overlay
|
||||
* would vanish instead of closing. The body reports its own exit via
|
||||
* `onExited` (the content's real `animationend`), so nothing here has to know
|
||||
* how long that animation is — the CSS owns the duration.
|
||||
*
|
||||
* The `openCount` key remounts the body per open, which is what lets local
|
||||
* search/sub-page state reset without a close effect.
|
||||
*/
|
||||
export function CommandPalette() {
|
||||
const { t } = useI18n()
|
||||
const open = useStore($commandPaletteOpen)
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [openCount, setOpenCount] = useState(0)
|
||||
|
||||
const retire = useCallback(() => {
|
||||
// Only retire the body if the palette is still closed — a reopen mid-fade
|
||||
// must not unmount the fresh instance.
|
||||
if (!$commandPaletteOpen.get()) {
|
||||
setMounted(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setOpenCount(count => count + 1)
|
||||
setMounted(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Safety net for environments where the exit animation never runs (jsdom,
|
||||
// `animation: none`), so the body can't be stranded mounted. The real
|
||||
// unmount is `onExited` below; whichever fires first wins.
|
||||
const timer = setTimeout(retire, EXIT_FALLBACK_MS)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [open, retire])
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
|
||||
{mounted && <CommandPaletteBody key={openCount} onExited={retire} />}
|
||||
</DialogPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandPaletteBody({ onExited }: { onExited: () => void }) {
|
||||
const { t } = useI18n()
|
||||
const pendingPage = useStore($commandPalettePage)
|
||||
const bindings = useStore($bindings)
|
||||
const worktrees = useStore($repoWorktrees)
|
||||
const projectTree = useStore($projectTree)
|
||||
const navigate = useNavigate()
|
||||
const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme()
|
||||
const [search, setSearch] = useState('')
|
||||
|
|
@ -386,10 +534,6 @@ export function CommandPalette() {
|
|||
const clientApply = useStore($updateApply)
|
||||
const backendStatus = useStore($backendUpdateStatus)
|
||||
const backendApply = useStore($backendUpdateApply)
|
||||
// Running a keepOpen row (a toggle) changes state the rows themselves report,
|
||||
// so the groups have to rebuild without the palette closing. A counter keeps
|
||||
// that generic — the palette never learns which stores its contributions read.
|
||||
const [selectTick, setSelectTick] = useState(0)
|
||||
|
||||
const updateVersionLabel = useMemo(() => {
|
||||
const backend = connection?.mode === 'remote'
|
||||
|
|
@ -425,24 +569,44 @@ export function CommandPalette() {
|
|||
}
|
||||
}
|
||||
|
||||
// Server-backed sources for the type-to-search groups, fetched lazily while
|
||||
// the palette is open. react-query handles caching/dedup/staleness.
|
||||
// Live ⌘/⌃-held state while the palette is open: rows with a modifier
|
||||
// variant (projects) preview it by swapping their label. Window-level
|
||||
// listeners because focus sits in the search input; blur clears so a
|
||||
// ⌘-Tab away doesn't strand the preview on.
|
||||
const [modHeld, setModHeld] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const sync = (event: KeyboardEvent) => setModHeld(event.metaKey || event.ctrlKey)
|
||||
const clear = () => setModHeld(false)
|
||||
|
||||
window.addEventListener('keydown', sync, { capture: true })
|
||||
window.addEventListener('keyup', sync, { capture: true })
|
||||
window.addEventListener('blur', clear)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', sync, { capture: true })
|
||||
window.removeEventListener('keyup', sync, { capture: true })
|
||||
window.removeEventListener('blur', clear)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Server-backed sources for the type-to-search groups. This component only
|
||||
// exists while the palette is open, so the queries are inherently lazy — no
|
||||
// `enabled` gate needed. react-query handles caching/dedup/staleness, so a
|
||||
// reopen paints from cache and revalidates in the background.
|
||||
const configQuery = useQuery({
|
||||
queryKey: ['command-palette', 'config'],
|
||||
queryFn: getHermesConfigRecord,
|
||||
enabled: open
|
||||
queryFn: getHermesConfigRecord
|
||||
})
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
queryKey: ['command-palette', 'sessions'],
|
||||
queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
|
||||
enabled: open
|
||||
queryFn: () => listAllProfileSessions(200, 1, 'exclude')
|
||||
})
|
||||
|
||||
const archivedQuery = useQuery({
|
||||
queryKey: ['command-palette', 'archived'],
|
||||
queryFn: () => listAllProfileSessions(200, 0, 'only'),
|
||||
enabled: open
|
||||
queryFn: () => listAllProfileSessions(200, 0, 'only')
|
||||
})
|
||||
|
||||
const mcpServers = useMemo(() => {
|
||||
|
|
@ -456,21 +620,16 @@ export function CommandPalette() {
|
|||
const sessions = useMemo(() => (sessionsQuery.data?.sessions ?? []).map(toSessionEntry), [sessionsQuery.data])
|
||||
const archivedSessions = useMemo(() => (archivedQuery.data?.sessions ?? []).map(toSessionEntry), [archivedQuery.data])
|
||||
|
||||
// Reset the query/sub-page on close so it reopens clean.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearch('')
|
||||
setPage(null)
|
||||
}
|
||||
}, [open])
|
||||
// Search/sub-page are local to a mount, and this component remounts per open
|
||||
// (keyed by open count), so each open starts clean without a reset effect.
|
||||
|
||||
// Deep-link into a nested page (e.g. `/pet list` → pets picker).
|
||||
useEffect(() => {
|
||||
if (open && pendingPage) {
|
||||
if (pendingPage) {
|
||||
setPage(pendingPage)
|
||||
$commandPalettePage.set(null)
|
||||
}
|
||||
}, [open, pendingPage])
|
||||
}, [pendingPage])
|
||||
|
||||
const go = useCallback((path: string) => () => navigateToWorkspacePage(navigate, path), [navigate])
|
||||
|
||||
|
|
@ -505,6 +664,11 @@ export function CommandPalette() {
|
|||
[t.settings.fieldLabels]
|
||||
)
|
||||
|
||||
// Running a keepOpen row (a toggle) changes state the rows themselves report,
|
||||
// so the groups have to rebuild without the palette closing. A counter keeps
|
||||
// that generic — the palette never learns which stores its contributions read.
|
||||
const [selectTick, setSelectTick] = useState(0)
|
||||
|
||||
const contributedItems = usePaletteContributions()
|
||||
|
||||
// The active repo's worktrees → "new conversation in <branch>". This is the
|
||||
|
|
@ -538,32 +702,38 @@ export function CommandPalette() {
|
|||
const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}`
|
||||
const cc = t.commandCenter
|
||||
|
||||
// Registry-contributed rows (core features + plugins) — one group, omitted
|
||||
// while nothing contributes.
|
||||
const commandGroup: PaletteGroup[] =
|
||||
contributedItems.length > 0
|
||||
? [
|
||||
{
|
||||
heading: cc.commands,
|
||||
items: contributedItems.map(item => ({
|
||||
action: item.action,
|
||||
// Read on open and after every select (the deps below), so a
|
||||
// row that reports state can't show the state it just left.
|
||||
detail: item.detail?.(),
|
||||
detailVariant: item.detailVariant,
|
||||
icon: item.icon ?? Zap,
|
||||
id: item.key,
|
||||
keepOpen: item.keepOpen,
|
||||
keywords: item.keywords,
|
||||
label: item.label,
|
||||
run: item.run
|
||||
}))
|
||||
}
|
||||
]
|
||||
: []
|
||||
// Projects are the primary way the desktop scopes work, so they're jumpable
|
||||
// from the palette. Plain select is a pure scope switch (sidebar enters the
|
||||
// project — never spends main); ⌘-Enter / ⌘-click also starts a new session
|
||||
// at the project root (stacked as a tab when main holds a chat), previewed
|
||||
// by the label swap while ⌘ is held. Rows carry the project's own codicon,
|
||||
// matching the sidebar. The pinned "Open folder…" row is the ⌘O upsert.
|
||||
const projectGroup: PaletteGroup = {
|
||||
heading: cc.projects,
|
||||
items: [
|
||||
{
|
||||
action: 'workspace.openFolder',
|
||||
icon: codiconIcon('folder-opened'),
|
||||
id: 'project-open-folder',
|
||||
keywords: ['open', 'folder', 'directory', 'project', 'add', 'import', 'workspace'],
|
||||
label: cc.openFolder,
|
||||
run: () => void openFolderAsProject()
|
||||
},
|
||||
...projectTree.map(project => ({
|
||||
comboHint: 'mod+enter',
|
||||
icon: codiconIcon(project.icon || (project.isNoProject ? 'home' : 'folder-library')),
|
||||
id: `project-${project.id}`,
|
||||
keywords: ['project', 'workspace', 'go to', project.label, ...(project.path ? [project.path] : [])],
|
||||
label: project.label,
|
||||
modLabel: cc.newSessionInProject(project.label),
|
||||
runWithEvent: (event?: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }) =>
|
||||
goToProject(project.id, { newSession: Boolean(event?.metaKey || event?.ctrlKey) })
|
||||
}))
|
||||
]
|
||||
}
|
||||
|
||||
// Group order is the tiebreaker rankGroups falls back on (stable sort), and
|
||||
// exact ties are the common case — "yolo" hits both "Toggle YOLO" and a
|
||||
// exact ties are the common case — "yolo" hits both "Toggle yolo" and a
|
||||
// worktree named bb/yolo-* as a whole word. So this order IS the priority:
|
||||
// where you're going, then what you can do, then what you can configure.
|
||||
return [
|
||||
|
|
@ -646,7 +816,29 @@ export function CommandPalette() {
|
|||
}
|
||||
]
|
||||
},
|
||||
...commandGroup,
|
||||
projectGroup,
|
||||
// Registry-contributed rows (core features + plugins) — one group,
|
||||
// omitted while nothing contributes.
|
||||
...(contributedItems.length > 0
|
||||
? [
|
||||
{
|
||||
heading: cc.commands,
|
||||
items: contributedItems.map(item => ({
|
||||
action: item.action,
|
||||
// Read on mount and after every select (the deps below), so a
|
||||
// row that reports state can't show the state it just left.
|
||||
detail: item.detail?.(),
|
||||
detailVariant: item.detailVariant,
|
||||
icon: item.icon ?? Zap,
|
||||
id: item.key,
|
||||
keepOpen: item.keepOpen,
|
||||
keywords: item.keywords,
|
||||
label: item.label,
|
||||
run: item.run
|
||||
}))
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
heading: cc.commandCenter,
|
||||
items: [
|
||||
|
|
@ -744,11 +936,11 @@ export function CommandPalette() {
|
|||
]
|
||||
}
|
||||
]
|
||||
// `open` and `selectTick` are deliberate re-read triggers, not values: rows
|
||||
// report live state through `detail()`, so the groups must rebuild when the
|
||||
// palette opens and after each select — eslint only sees unused deps.
|
||||
// `selectTick` is a deliberate re-read trigger, not a value: rows report
|
||||
// live state through `detail()`, so the groups must rebuild after a select
|
||||
// that kept the palette open — eslint only sees an unused dep.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [contributedItems, go, open, selectTick, settingsSectionLabel, t, updateVersionLabel])
|
||||
}, [contributedItems, go, projectTree, selectTick, settingsSectionLabel, t, updateVersionLabel])
|
||||
|
||||
// The long, granular lists (settings fields, API keys, MCP servers, archived
|
||||
// chats) only surface once the user types — otherwise they'd bury the
|
||||
|
|
@ -778,6 +970,23 @@ export function CommandPalette() {
|
|||
})
|
||||
}
|
||||
|
||||
// Paste/type an absolute folder path → open it as a project directly (the
|
||||
// ⌘O upsert without the native picker). Same reflex as the raw-session-id
|
||||
// row above.
|
||||
if (FOLDER_PATH_RE.test(directId)) {
|
||||
result.push({
|
||||
items: [
|
||||
{
|
||||
icon: codiconIcon('folder-opened'),
|
||||
id: `open-folder-${directId}`,
|
||||
keywords: ['open', 'folder', 'project', directId],
|
||||
label: t.commandCenter.openFolderAt(directId),
|
||||
run: () => void openFolderAsProject(directId)
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
// Deep-link straight to a Capabilities sub-tab. The root "Go to" entry only
|
||||
// lands on the top-level Skills view; typing "mcp"/"tools"/"skills" should
|
||||
// jump to the exact tab (matches the "not just the top lvl" ask).
|
||||
|
|
@ -1048,101 +1257,92 @@ export function CommandPalette() {
|
|||
}
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
|
||||
<DialogPrimitive.Portal>
|
||||
{/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */}
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-(--z-over-modal)" />
|
||||
<DialogPrimitive.Content
|
||||
aria-describedby={undefined}
|
||||
className={cn(
|
||||
HUD_POSITION,
|
||||
HUD_SURFACE,
|
||||
'z-(--z-over-modal-content) w-[min(34rem,calc(100vw-2rem))] overflow-hidden duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95'
|
||||
<DialogPrimitive.Portal>
|
||||
{/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */}
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-(--z-over-modal)" />
|
||||
<DialogPrimitive.Content
|
||||
aria-describedby={undefined}
|
||||
className={cn(
|
||||
HUD_POSITION,
|
||||
HUD_SURFACE,
|
||||
'z-(--z-over-modal-content) w-[min(34rem,calc(100vw-2rem))] overflow-hidden duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95'
|
||||
)}
|
||||
// The close animation finishing is what retires this whole subtree —
|
||||
// the CSS owns the duration, not a hardcoded timer. Guarded on the
|
||||
// content itself (descendants animate too) and on the closed state, so
|
||||
// an OPEN animation never unmounts the palette we just opened.
|
||||
onAnimationEnd={event => {
|
||||
if (event.target === event.currentTarget && event.currentTarget.dataset.state === 'closed') {
|
||||
onExited()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>
|
||||
<Command className="bg-transparent" loop shouldFilter={false}>
|
||||
{activePage && (
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={goBack}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
<span>{t.commandCenter.back}</span>
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
<span className="font-medium text-foreground">{activePage.title}</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>
|
||||
<Command className="bg-transparent" loop shouldFilter={false}>
|
||||
{activePage && (
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={goBack}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
<span>{t.commandCenter.back}</span>
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
<span className="font-medium text-foreground">{activePage.title}</span>
|
||||
</button>
|
||||
<CommandInput
|
||||
className={HUD_TEXT}
|
||||
onKeyDown={event => {
|
||||
// Capture modifiers before cmdk's Enter fires onSelect (which
|
||||
// swipes the inviting MouseEvent and hands us nothing).
|
||||
noteSelectMods(event)
|
||||
|
||||
if (!activePage) {
|
||||
return
|
||||
}
|
||||
|
||||
// In a submenu: Esc and empty-input Backspace step back out
|
||||
// instead of closing the whole palette.
|
||||
if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
goBack()
|
||||
|
||||
return
|
||||
}
|
||||
}}
|
||||
onValueChange={setSearch}
|
||||
placeholder={placeholder}
|
||||
right={page === 'pets' ? <PetInlineToggle /> : undefined}
|
||||
value={search}
|
||||
/>
|
||||
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
|
||||
{/* Server-driven pages render their own list; the rest show groups. */}
|
||||
{page === 'pets' ? (
|
||||
<PetPalettePage
|
||||
onGenerate={() => {
|
||||
closeCommandPalette()
|
||||
openPetGenerate()
|
||||
}}
|
||||
search={search}
|
||||
/>
|
||||
) : page === 'install-theme' ? (
|
||||
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
|
||||
) : (
|
||||
<PaletteGroups
|
||||
bindings={bindings}
|
||||
groups={visibleGroups}
|
||||
modHeld={modHeld}
|
||||
noResultsLabel={t.commandCenter.noResults}
|
||||
onSelectItem={handleSelect}
|
||||
onSelectMods={noteSelectMods}
|
||||
search={search}
|
||||
/>
|
||||
)}
|
||||
<CommandInput
|
||||
className={HUD_TEXT}
|
||||
onKeyDown={event => {
|
||||
// Capture modifiers before cmdk's Enter fires onSelect (which
|
||||
// swipes the inviting MouseEvent and hands us nothing).
|
||||
noteSelectMods(event)
|
||||
|
||||
if (!activePage) {
|
||||
return
|
||||
}
|
||||
|
||||
// In a submenu: Esc and empty-input Backspace step back out
|
||||
// instead of closing the whole palette.
|
||||
if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
goBack()
|
||||
|
||||
return
|
||||
}
|
||||
}}
|
||||
onValueChange={setSearch}
|
||||
placeholder={placeholder}
|
||||
right={page === 'pets' ? <PetInlineToggle /> : undefined}
|
||||
value={search}
|
||||
/>
|
||||
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
|
||||
{/* Server-driven pages render their own list; the rest show groups. */}
|
||||
{page === 'pets' ? (
|
||||
<PetPalettePage
|
||||
onGenerate={() => {
|
||||
closeCommandPalette()
|
||||
openPetGenerate()
|
||||
}}
|
||||
search={search}
|
||||
/>
|
||||
) : page === 'install-theme' ? (
|
||||
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
|
||||
) : (
|
||||
<>
|
||||
{/* Filtering happens in rankGroups, so cmdk's own CommandEmpty
|
||||
(keyed to its internal filter count) would never fire. */}
|
||||
{visibleGroups.length === 0 && (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">{t.commandCenter.noResults}</div>
|
||||
)}
|
||||
{visibleGroups.map((group, index) => (
|
||||
<CommandGroup
|
||||
className={HUD_HEADING}
|
||||
heading={group.heading}
|
||||
key={group.heading ?? `palette-group-${index}`}
|
||||
>
|
||||
{group.items.map(item => (
|
||||
<PaletteRow
|
||||
bindings={bindings}
|
||||
item={item}
|
||||
key={item.id}
|
||||
onSelectItem={handleSelect}
|
||||
onSelectMods={noteSelectMods}
|
||||
search={search}
|
||||
/>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { openSession } from '@/app/open-session'
|
|||
import { storedSessionIdForNotification } from '@/lib/session-ids'
|
||||
import { respondToApprovalAction } from '@/store/native-notifications'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import { openFolderAsProject } from '@/store/projects'
|
||||
import {
|
||||
$sessions,
|
||||
getRememberedRoute,
|
||||
|
|
@ -187,6 +188,13 @@ export function useDesktopIntegrations({
|
|||
return () => unsubscribe?.()
|
||||
}, [navigate])
|
||||
|
||||
// File > Open Folder… — same open-folder-as-project upsert as the ⌘O keybind.
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onOpenFolderRequested?.(() => void openFolderAsProject())
|
||||
|
||||
return () => unsubscribe?.()
|
||||
}, [])
|
||||
|
||||
// Another window mutated the shared session list -> re-pull the sidebar.
|
||||
useEffect(() => {
|
||||
if (isSecondaryWindow()) {
|
||||
|
|
|
|||
|
|
@ -528,7 +528,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
|||
}
|
||||
|
||||
lastStartWorkTokenRef.current = startWorkSessionRequest.token
|
||||
startSessionInWorkspace(startWorkSessionRequest.path)
|
||||
startSessionInWorkspace(startWorkSessionRequest.path, { openTab: startWorkSessionRequest.openTab })
|
||||
|
||||
if (startWorkSessionRequest.draft) {
|
||||
requestComposerInsert(startWorkSessionRequest.draft, { target: 'main' })
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import {
|
|||
switchToDefaultProfile,
|
||||
toggleShowAllProfiles
|
||||
} from '@/store/profile'
|
||||
import { requestNewWorktree } from '@/store/projects'
|
||||
import { openFolderAsProject, requestNewWorktree } from '@/store/projects'
|
||||
import { toggleReview } from '@/store/review'
|
||||
import { setModelPickerOpen } from '@/store/session'
|
||||
import { reopenLastClosedTile } from '@/store/session-states'
|
||||
|
|
@ -174,6 +174,9 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||
// Only meaningful inside a git repo — a no-op otherwise (the key falls
|
||||
// through instead of silently doing nothing).
|
||||
'workspace.newWorktree': () => $repoStatus.get() && requestNewWorktree(),
|
||||
// ⌘O: native folder picker → open the folder as a project (upsert) with a
|
||||
// fresh session anchored there.
|
||||
'workspace.openFolder': () => void openFolderAsProject(),
|
||||
|
||||
// Narrow-viewport reveal is handled inside the store toggles now.
|
||||
'view.toggleSidebar': toggleSidebarOpen,
|
||||
|
|
|
|||
|
|
@ -100,16 +100,8 @@ const _chatMessageFieldsExhaustive: {
|
|||
[K in Exclude<keyof ChatMessage, (typeof COMPARED_FIELDS)[number] | (typeof IGNORED_FIELDS)[number]>]: never
|
||||
} = {}
|
||||
|
||||
const COMPARED_FIELDS = [
|
||||
'id',
|
||||
'role',
|
||||
'pending',
|
||||
'error',
|
||||
'hidden',
|
||||
'branchGroupId',
|
||||
'interim',
|
||||
'reactions'
|
||||
] as const
|
||||
const COMPARED_FIELDS = ['id', 'role', 'pending', 'error', 'hidden', 'branchGroupId', 'interim', 'reactions'] as const
|
||||
|
||||
const IGNORED_FIELDS = ['timestamp', 'attachmentRefs', 'parts', 'rowId'] as const
|
||||
|
||||
// Compile-time check: every ChatMessagePart discriminant must be handled by
|
||||
|
|
@ -193,10 +185,7 @@ export function chatReactionsEquivalent(a: ChatMessage['reactions'], b: ChatMess
|
|||
|
||||
return (
|
||||
aList.length === bList.length &&
|
||||
aList.every(
|
||||
(reaction, index) =>
|
||||
reaction.emoji === bList[index].emoji && reaction.author === bList[index].author
|
||||
)
|
||||
aList.every((reaction, index) => reaction.emoji === bList[index].emoji && reaction.author === bList[index].author)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -173,11 +173,13 @@ export function KeybindSettings() {
|
|||
function CategoryHeader({ label, onToggle, open }: { label: string; onToggle: () => void; open: boolean }) {
|
||||
return (
|
||||
<button
|
||||
className="group/kbd-cat flex w-fit items-center gap-1 px-2.5 pb-1 pt-3 text-left leading-none"
|
||||
className="group/kbd-cat flex w-fit min-w-0 items-center gap-1 px-2.5 pb-1 pt-3 text-left leading-none"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
<span className="text-[0.64rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70">{label}</span>
|
||||
<span className="min-w-0 truncate text-[0.64rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70">
|
||||
{label}
|
||||
</span>
|
||||
<DisclosureCaret
|
||||
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/kbd-cat:opacity-100"
|
||||
open={open}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildGroups, firstVisibleGroupIndex, isVirtualizedGroup, LIVE_TAIL_GROUPS, type MessageGroup } from './list'
|
||||
import {
|
||||
buildGroups,
|
||||
firstVisibleGroupIndex,
|
||||
LIVE_TAIL_MIN_GROUPS,
|
||||
LIVE_TAIL_PARTS,
|
||||
liveTailStart,
|
||||
type MessageGroup
|
||||
} from './list'
|
||||
|
||||
// Signature rows are `${index}:${id}:${role}:${weight}` (see the useAuiState
|
||||
// selector in list.tsx).
|
||||
|
|
@ -81,32 +88,79 @@ describe('firstVisibleGroupIndex', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('isVirtualizedGroup', () => {
|
||||
it('never virtualizes the newest turns (the live tail)', () => {
|
||||
const count = 20
|
||||
describe('liveTailStart', () => {
|
||||
const group = (id: string, weight: number): MessageGroup => ({ id, index: 0, kind: 'standalone', weight })
|
||||
|
||||
for (let i = count - LIVE_TAIL_GROUPS; i < count; i++) {
|
||||
expect(isVirtualizedGroup(i, count)).toBe(false)
|
||||
}
|
||||
it('keeps the newest turns rendered until the parts budget is spent', () => {
|
||||
// 10 turns x 10 parts. A 40-part tail covers the newest 4-5 turns.
|
||||
const groups = Array.from({ length: 10 }, (_, i) => group(`g${i}`, 10))
|
||||
const start = liveTailStart(groups)
|
||||
|
||||
expect(start).toBeGreaterThan(0)
|
||||
expect(start).toBeLessThan(groups.length)
|
||||
|
||||
// Everything from `start` onward is the live tail...
|
||||
const tailParts = groups.slice(start).reduce((sum, g) => sum + g.weight, 0)
|
||||
expect(tailParts).toBeGreaterThan(LIVE_TAIL_PARTS)
|
||||
|
||||
// ...and dropping its oldest member puts it back under budget, i.e. the
|
||||
// tail is minimal rather than sprawling.
|
||||
const withoutOldest = groups.slice(start + 1).reduce((sum, g) => sum + g.weight, 0)
|
||||
expect(withoutOldest).toBeLessThanOrEqual(LIVE_TAIL_PARTS)
|
||||
})
|
||||
|
||||
it('virtualizes older turns that sit before the live tail', () => {
|
||||
const count = 20
|
||||
it('virtualizes the old bulk of a long agent transcript', () => {
|
||||
// The regression this guards: heavy tool turns. A turn-count tail (6) left
|
||||
// NOTHING virtualized on transcripts like this, so every Radix overlay open
|
||||
// paid a whole-document style recalc.
|
||||
const groups = Array.from({ length: 40 }, (_, i) => group(`g${i}`, 120))
|
||||
|
||||
expect(isVirtualizedGroup(0, count)).toBe(true)
|
||||
expect(isVirtualizedGroup(count - LIVE_TAIL_GROUPS - 1, count)).toBe(true)
|
||||
// Only the min-group floor stays rendered; the other 38 turns skip.
|
||||
expect(liveTailStart(groups)).toBe(groups.length - LIVE_TAIL_MIN_GROUPS)
|
||||
})
|
||||
|
||||
it('never virtualizes below the min-group floor, however heavy the turns', () => {
|
||||
const groups = Array.from({ length: 5 }, (_, i) => group(`g${i}`, 10_000))
|
||||
|
||||
expect(liveTailStart(groups)).toBe(groups.length - LIVE_TAIL_MIN_GROUPS)
|
||||
})
|
||||
|
||||
it('keeps every turn rendered when the whole transcript fits in the tail', () => {
|
||||
const count = LIVE_TAIL_GROUPS
|
||||
const groups = [group('a', 5), group('b', 5), group('c', 5)]
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
expect(isVirtualizedGroup(i, count)).toBe(false)
|
||||
expect(liveTailStart(groups)).toBe(0)
|
||||
})
|
||||
|
||||
it('handles an empty transcript', () => {
|
||||
expect(liveTailStart([])).toBe(0)
|
||||
})
|
||||
|
||||
it('honors a custom budget', () => {
|
||||
const groups = Array.from({ length: 10 }, (_, i) => group(`g${i}`, 1))
|
||||
|
||||
// A 3-part budget would keep 4 turns, but the max-groups ceiling is not hit
|
||||
// here, so the parts budget wins.
|
||||
expect(liveTailStart(groups, 3)).toBe(6)
|
||||
})
|
||||
|
||||
it('never renders more than the old turn-count tail did, on any shape', () => {
|
||||
// Guards the one way a parts budget can regress: a long transcript of tiny
|
||||
// turns, where walking back 40 parts reaches further than 6 turns would.
|
||||
const shapes = [
|
||||
Array.from({ length: 40 }, () => 4), // long chat, tiny turns
|
||||
Array.from({ length: 40 }, () => 1), // pathological: 1-part turns
|
||||
Array.from({ length: 12 }, () => 6),
|
||||
[80, 120, 60, 150, 90, 200, 70], // real agent tile
|
||||
[30, 45]
|
||||
]
|
||||
|
||||
for (const weights of shapes) {
|
||||
const groups = weights.map((weight, i) => group(`g${i}`, weight))
|
||||
const rendered = (start: number) => weights.slice(start).reduce((a, b) => a + b, 0)
|
||||
|
||||
const oldStart = Math.max(0, groups.length - 6)
|
||||
|
||||
expect(rendered(liveTailStart(groups))).toBeLessThanOrEqual(rendered(oldStart))
|
||||
}
|
||||
})
|
||||
|
||||
it('honors a custom tail size', () => {
|
||||
expect(isVirtualizedGroup(5, 10, 3)).toBe(true)
|
||||
expect(isVirtualizedGroup(7, 10, 3)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -130,16 +130,63 @@ export function firstVisibleGroupIndex(groups: readonly MessageGroup[], budget:
|
|||
// stick-to-bottom lock drifts and the view creeps up over older turns — the
|
||||
// "long session eventually shows old responses" glitch.
|
||||
//
|
||||
// Keep the newest N turns always-rendered so a turn is only ever virtualized
|
||||
// Keep the newest turns always-rendered so a turn is only ever virtualized
|
||||
// once its layout has settled at its final size (remembered == real → skipping
|
||||
// it changes no height). Off-screen OLDER turns still skip, so the dialog/popover
|
||||
// recalc win on long transcripts is preserved (that scales with the hundreds of
|
||||
// old turns, not this small live tail).
|
||||
export const LIVE_TAIL_GROUPS = 6
|
||||
// recalc win on long transcripts is preserved.
|
||||
//
|
||||
// The tail is budgeted in PARTS, not turns, because that is what the cost
|
||||
// actually scales with — the same currency as RENDER_BUDGET / FIRST_PAINT_BUDGET.
|
||||
// A turn-count tail silently defeats itself on agent transcripts: one tool-heavy
|
||||
// turn is 50-200 parts, so a 6-TURN tail exempted the entire visible transcript
|
||||
// and nothing virtualized at all. Measured on a 5-tile window (7/3/5/3/2 groups
|
||||
// per tile): zero content-visibility containers were active, and every Radix
|
||||
// overlay open paid the full ~610ms whole-document recalc that #66470 fixed.
|
||||
//
|
||||
// 40 parts ≈ the 1-2 turns a viewport shows after scroll-to-bottom (the same
|
||||
// reasoning as FIRST_PAINT_BUDGET=20, doubled so a turn that grows mid-stream
|
||||
// doesn't fall out of the tail as it settles).
|
||||
export const LIVE_TAIL_PARTS = 40
|
||||
// Floor: always exempt at least this many turns regardless of weight, so a
|
||||
// transcript of very heavy turns still keeps the streaming one unvirtualized.
|
||||
export const LIVE_TAIL_MIN_GROUPS = 2
|
||||
// Ceiling: never exempt more than this many turns, however light they are. On a
|
||||
// long transcript of tiny turns a parts-only budget would walk back further
|
||||
// than the old turn-count tail did and virtualize LESS — this keeps the new
|
||||
// policy a strict improvement on every shape.
|
||||
export const LIVE_TAIL_MAX_GROUPS = 6
|
||||
|
||||
/** True when a visible group is old enough to virtualize (outside the live tail). */
|
||||
export function isVirtualizedGroup(indexInVisible: number, visibleCount: number, liveTail = LIVE_TAIL_GROUPS): boolean {
|
||||
return indexInVisible < visibleCount - liveTail
|
||||
/**
|
||||
* Index of the newest group that still virtualizes — everything at or after it
|
||||
* is the live tail and stays rendered. Walks newest-first accumulating parts,
|
||||
* so the tail covers a viewport's worth of content rather than a fixed number
|
||||
* of turns, clamped to [MIN, MAX] turns. Computed once per render, not per row.
|
||||
*/
|
||||
export function liveTailStart(
|
||||
groups: readonly MessageGroup[],
|
||||
tailParts = LIVE_TAIL_PARTS,
|
||||
minGroups = LIVE_TAIL_MIN_GROUPS,
|
||||
maxGroups = LIVE_TAIL_MAX_GROUPS
|
||||
): number {
|
||||
let parts = 0
|
||||
let start = groups.length
|
||||
|
||||
for (let i = groups.length - 1; i >= 0; i--) {
|
||||
parts += groups[i]?.weight ?? 1
|
||||
start = i
|
||||
|
||||
if (parts > tailParts) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp the tail to [minGroups, maxGroups] turns: the floor keeps the live
|
||||
// turn rendered when turns are huge, the ceiling stops a tail of tiny turns
|
||||
// from sprawling past what the old turn-count policy rendered.
|
||||
const floor = Math.max(0, groups.length - minGroups)
|
||||
const ceiling = Math.max(0, groups.length - maxGroups)
|
||||
|
||||
return Math.min(floor, Math.max(ceiling, start))
|
||||
}
|
||||
|
||||
const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
|
|
@ -278,6 +325,15 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||
|
||||
const hiddenCount = firstVisibleGroupIndex(weightedGroups, renderBudget)
|
||||
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
|
||||
|
||||
// Where the always-rendered live tail begins. Derived from the WEIGHTED
|
||||
// groups (parts, not turns) so the tail is a viewport's worth of content —
|
||||
// see liveTailStart. Computed once here rather than per row.
|
||||
const tailStart = useMemo(
|
||||
() => liveTailStart(hiddenCount > 0 ? weightedGroups.slice(hiddenCount) : weightedGroups),
|
||||
[weightedGroups, hiddenCount]
|
||||
)
|
||||
|
||||
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
|
||||
// hide the titlebar tool cluster + session header, but the OS traffic lights
|
||||
// still sit in the top-left, so reserve the titlebar gap above the transcript.
|
||||
|
|
@ -436,12 +492,11 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||
// The live tail (newest turns) is exempt: virtualizing a turn
|
||||
// whose final size hasn't been remembered yet snaps it to a stale
|
||||
// height when it scrolls off, drifting stick-to-bottom up over old
|
||||
// turns. See isVirtualizedGroup.
|
||||
// turns. See liveTailStart.
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)',
|
||||
isVirtualizedGroup(indexInVisible, visibleGroups.length) &&
|
||||
'[contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]'
|
||||
indexInVisible < tailStart && '[contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]'
|
||||
)}
|
||||
key={group.id}
|
||||
>
|
||||
|
|
@ -461,7 +516,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||
</MessageRenderBoundary>
|
||||
</div>
|
||||
)),
|
||||
[visibleGroups, components, structuralSignature]
|
||||
[visibleGroups, components, structuralSignature, tailStart]
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -117,10 +117,7 @@ export const ReactionPicker: FC<{
|
|||
// Opt this one surface out of the shared popover glass: emoji hover
|
||||
// tints at 15% alpha are unreadable over blurred transcript text.
|
||||
// Overriding the local surface var keeps the arrow matched for free.
|
||||
className={cn(
|
||||
'w-auto p-1 [--popover-surface:var(--ui-bg-elevated)]',
|
||||
!expanded && 'flex gap-0.5'
|
||||
)}
|
||||
className={cn('w-auto p-1 [--popover-surface:var(--ui-bg-elevated)]', !expanded && 'flex gap-0.5')}
|
||||
onCloseAutoFocus={event => event.preventDefault()}
|
||||
side="top"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
const editor = editorRef.current
|
||||
|
||||
if (editor) {
|
||||
renderComposerContents(editor, next)
|
||||
renderComposerContents(editor, next, { trailingCommitted: true })
|
||||
placeCaretEnd(editor)
|
||||
}
|
||||
|
||||
|
|
@ -187,7 +187,11 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
editor &&
|
||||
(editor.childNodes.length === 0 || (document.activeElement !== editor && composerPlainText(editor) !== draft))
|
||||
) {
|
||||
renderComposerContents(editor, draft)
|
||||
// Inert by construction — this repaints on mount or when the editor
|
||||
// isn't the one being typed into. A message opened for edit is finished
|
||||
// text, so a `/command` ending it is committed and chips, matching how
|
||||
// the transcript rendered that same message a moment ago.
|
||||
renderComposerContents(editor, draft, { trailingCommitted: true })
|
||||
|
||||
if (document.activeElement === editor) {
|
||||
placeCaretEnd(editor)
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ import {
|
|||
restoreTreePane,
|
||||
SESSION_TILE_DRAG,
|
||||
setTreeGroupHeaderHidden,
|
||||
splitTreeZone,
|
||||
setTreeGroupMinimized
|
||||
setTreeGroupMinimized,
|
||||
splitTreeZone
|
||||
} from '../store'
|
||||
|
||||
import { type DoubleTapContext, startPaneDrag } from './drag-session'
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ interface DisclosureCaretProps extends Omit<CodiconProps, 'name'> {
|
|||
export function DisclosureCaret({ className, open, size = '0.75rem', ...props }: DisclosureCaretProps) {
|
||||
return (
|
||||
<Codicon
|
||||
className={cn('transition-transform duration-150', open && 'rotate-90', className)}
|
||||
className={cn('shrink-0 transition-transform duration-150', open && 'rotate-90', className)}
|
||||
name="chevron-right"
|
||||
size={size}
|
||||
{...props}
|
||||
|
|
|
|||
1
apps/desktop/src/global.d.ts
vendored
1
apps/desktop/src/global.d.ts
vendored
|
|
@ -241,6 +241,7 @@ declare global {
|
|||
write: (id: string, data: string) => Promise<boolean>
|
||||
}
|
||||
onClosePreviewRequested?: (callback: () => void) => () => void
|
||||
onOpenFolderRequested?: (callback: () => void) => () => void
|
||||
onOpenUpdatesRequested?: (callback: () => void) => () => void
|
||||
onDeepLink?: (
|
||||
callback: (payload: { kind: string; name: string; params: Record<string, string> }) => void
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ export const ar = defineLocale({
|
|||
'session.focusSearch': 'البحث في الجلسات',
|
||||
'session.togglePin': 'تثبيت / إلغاء تثبيت الجلسة الحالية',
|
||||
'workspace.newWorktree': 'worktree جديد',
|
||||
'workspace.openFolder': 'فتح مجلد كمشروع',
|
||||
'composer.focus': 'التركيز على المحرّر',
|
||||
'composer.modelPicker': 'فتح منتقي النموذج',
|
||||
'composer.voice': 'بدء / إيقاف المحادثة الصوتية',
|
||||
|
|
|
|||
|
|
@ -251,6 +251,7 @@ export const en: Translations = {
|
|||
'session.focusSearch': 'Search sessions',
|
||||
'session.togglePin': 'Pin / unpin current session',
|
||||
'workspace.newWorktree': 'New worktree',
|
||||
'workspace.openFolder': 'Open folder as project',
|
||||
'composer.focus': 'Focus composer',
|
||||
'composer.modelPicker': 'Open model picker',
|
||||
'composer.voice': 'Start / stop voice conversation',
|
||||
|
|
@ -1145,6 +1146,10 @@ export const en: Translations = {
|
|||
goTo: 'Go to',
|
||||
goToSession: 'Go to session',
|
||||
branches: 'Branches',
|
||||
projects: 'Projects',
|
||||
openFolder: 'Open folder as project…',
|
||||
openFolderAt: path => `Open folder as project — ${path}`,
|
||||
newSessionInProject: project => `New session in ${project}`,
|
||||
commands: 'Commands',
|
||||
startInBranch: branch => `New conversation in ${branch}`,
|
||||
commandCenter: 'Command Center',
|
||||
|
|
|
|||
|
|
@ -1008,6 +1008,10 @@ export interface Translations {
|
|||
goTo: string
|
||||
goToSession: string
|
||||
branches: string
|
||||
projects: string
|
||||
openFolder: string
|
||||
openFolderAt: (path: string) => string
|
||||
newSessionInProject: (project: string) => string
|
||||
commands: string
|
||||
startInBranch: (branch: string) => string
|
||||
commandCenter: string
|
||||
|
|
|
|||
|
|
@ -246,6 +246,7 @@ export const zh: Translations = {
|
|||
'session.focusSearch': '搜索会话',
|
||||
'session.togglePin': '固定/取消固定当前会话',
|
||||
'workspace.newWorktree': '新建工作树',
|
||||
'workspace.openFolder': '打开文件夹为项目',
|
||||
'composer.focus': '聚焦输入框',
|
||||
'composer.modelPicker': '打开模型选择器',
|
||||
'composer.voice': '开始 / 停止语音对话',
|
||||
|
|
@ -1342,6 +1343,10 @@ export const zh: Translations = {
|
|||
goTo: '前往',
|
||||
goToSession: '前往会话',
|
||||
branches: '分支',
|
||||
projects: '项目',
|
||||
openFolder: '打开文件夹为项目…',
|
||||
openFolderAt: path => `打开文件夹为项目 — ${path}`,
|
||||
newSessionInProject: project => `在 ${project} 中新建会话`,
|
||||
commands: '命令',
|
||||
startInBranch: branch => `在 ${branch} 中开始新对话`,
|
||||
commandCenter: '命令中心',
|
||||
|
|
|
|||
|
|
@ -88,6 +88,11 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
|
|||
{ id: 'session.togglePin', category: 'session', defaults: [] },
|
||||
// ⌘⇧B — "b" for branch: spin up a new git worktree from the active repo.
|
||||
{ id: 'workspace.newWorktree', category: 'session', defaults: ['mod+shift+b'] },
|
||||
// ⌘O — the editor-standard "open folder" chord (VS Code ⌘O, Zed's
|
||||
// workspace::Open). Picks a folder and opens it as a project (upsert:
|
||||
// enters the owning project when one exists, else creates one), landing on
|
||||
// a fresh session anchored there.
|
||||
{ id: 'workspace.openFolder', category: 'session', defaults: ['mod+o'] },
|
||||
|
||||
// ── Navigation ───────────────────────────────────────────────────────────
|
||||
{ id: 'nav.commandPalette', category: 'navigation', defaults: ['mod+k', 'mod+p'] },
|
||||
|
|
|
|||
|
|
@ -161,6 +161,36 @@ export function exitProjectScope(): void {
|
|||
$projectScope.set(ALL_PROJECTS)
|
||||
}
|
||||
|
||||
// A project's working root: its primary folder, else the first repo that has
|
||||
// one. Empty for the path-less Home bucket. (The sidebar's `projectTreeCwd` is
|
||||
// the same rule over the same tree — this is the store-side copy so the store
|
||||
// doesn't reach into the sidebar's React module.)
|
||||
const projectRootCwd = (project: SidebarProjectTree | undefined): string =>
|
||||
(project?.path || project?.repos.find(repo => repo.path)?.path || '').trim()
|
||||
|
||||
// ⌘K "go to project": flip the sidebar into grouped mode and enter the project
|
||||
// — a pure scope switch, same as clicking the overview row (never spends main).
|
||||
// With `newSession` (⌘-select / ⌘-Enter) it also lands on a fresh session draft
|
||||
// anchored at the project root — stacked as a tab when main already holds a
|
||||
// chat (palette opens are opens-from-nowhere). A path-less project (the Home
|
||||
// bucket) gets a plain detached draft.
|
||||
export function goToProject(id: string, options?: { newSession?: boolean }): void {
|
||||
setSidebarAgentsGrouped(true)
|
||||
enterProject(id)
|
||||
|
||||
if (!options?.newSession) {
|
||||
return
|
||||
}
|
||||
|
||||
const cwd = projectRootCwd($projectTree.get().find(node => node.id === id))
|
||||
|
||||
if (cwd) {
|
||||
requestStartWorkSession(cwd, undefined, { openTab: true })
|
||||
} else {
|
||||
requestFreshSession()
|
||||
}
|
||||
}
|
||||
|
||||
// The cwd a NEW chat should start in. The "active project" is just an atom
|
||||
// ($projectScope) — so when you're inside a project, a new session (cmd-n, the
|
||||
// trunk "+") starts at that project's root (its primary repo = the default-branch
|
||||
|
|
@ -177,8 +207,7 @@ export function resolveNewSessionCwd(): string {
|
|||
}
|
||||
|
||||
if (scope !== ALL_PROJECTS) {
|
||||
const project = $projectTree.get().find(node => node.id === scope)
|
||||
const cwd = (project?.path || project?.repos.find(repo => repo.path)?.path || '').trim()
|
||||
const cwd = projectRootCwd($projectTree.get().find(node => node.id === scope))
|
||||
|
||||
if (cwd) {
|
||||
return cwd
|
||||
|
|
@ -997,6 +1026,8 @@ export async function switchBranchInRepo(repoPath: string, branch: string): Prom
|
|||
// effect even if the path repeats.
|
||||
export interface StartWorkSessionRequest {
|
||||
draft?: string
|
||||
/** Stack the fresh session as a tab when main already holds a chat (palette/⌘O opens-from-nowhere). */
|
||||
openTab?: boolean
|
||||
path: string
|
||||
token: number
|
||||
}
|
||||
|
|
@ -1016,7 +1047,7 @@ export function requestNewWorktree(): void {
|
|||
|
||||
let startWorkToken = 0
|
||||
|
||||
export function requestStartWorkSession(path: string, draft?: string): void {
|
||||
export function requestStartWorkSession(path: string, draft?: string, options?: { openTab?: boolean }): void {
|
||||
const target = path.trim()
|
||||
|
||||
if (!target) {
|
||||
|
|
@ -1024,7 +1055,12 @@ export function requestStartWorkSession(path: string, draft?: string): void {
|
|||
}
|
||||
|
||||
startWorkToken += 1
|
||||
$startWorkSessionRequest.set({ draft: draft?.trim() || undefined, path: target, token: startWorkToken })
|
||||
$startWorkSessionRequest.set({
|
||||
draft: draft?.trim() || undefined,
|
||||
openTab: options?.openTab || undefined,
|
||||
path: target,
|
||||
token: startWorkToken
|
||||
})
|
||||
}
|
||||
|
||||
export async function removeWorktreePath(
|
||||
|
|
@ -1068,3 +1104,48 @@ export async function pickProjectFolder(): Promise<null | string> {
|
|||
|
||||
return dir || null
|
||||
}
|
||||
|
||||
// ⌘O / palette "Open folder…": open a folder AS a project, upserting. A folder
|
||||
// already covered by a project (explicit or auto) just enters it; anything else
|
||||
// becomes a new project named after the folder. Either way the sidebar scopes
|
||||
// to the project and a fresh session draft lands anchored at the folder — the
|
||||
// one-keystroke version of new project → enter → new session. Like goToProject,
|
||||
// this is an open-from-nowhere: an occupied main gets a stacked tab, not stolen.
|
||||
export async function openFolderAsProject(dir?: string): Promise<void> {
|
||||
const target = (dir ?? (await pickProjectFolder()) ?? '').trim()
|
||||
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh first so the membership check runs against live truth — a repo
|
||||
// cloned since the last scan should enter its auto project, not double-create.
|
||||
await refreshProjectTree()
|
||||
|
||||
const existing = projectIdForCwd(target)
|
||||
|
||||
if (existing) {
|
||||
setSidebarAgentsGrouped(true)
|
||||
enterProject(existing)
|
||||
} else {
|
||||
const name =
|
||||
target
|
||||
.replace(/[/\\]+$/, '')
|
||||
.split(/[/\\]/)
|
||||
.pop() || target
|
||||
|
||||
try {
|
||||
const created = await createProject({ name, folders: [target], primaryPath: target, use: true })
|
||||
|
||||
if (created) {
|
||||
enterProject(created.id)
|
||||
}
|
||||
} catch (err) {
|
||||
// Stale backend (no projects.* RPC) or a failed write: still open the
|
||||
// folder as a plain workspace session below — the project row can wait.
|
||||
notify({ kind: 'warning', message: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
requestStartWorkSession(target, undefined, { openTab: true })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,10 +62,7 @@ export async function toggleMessageReaction(
|
|||
const gateway = activeGateway()
|
||||
|
||||
if (!sessionId || !gateway) {
|
||||
notifyError(
|
||||
new Error(!sessionId ? 'No active session' : 'Gateway not connected'),
|
||||
'Could not react'
|
||||
)
|
||||
notifyError(new Error(!sessionId ? 'No active session' : 'Gateway not connected'), 'Could not react')
|
||||
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { group, split } from '@/components/pane-shell/tree/model'
|
||||
import { findGroupOfPane, group, split } from '@/components/pane-shell/tree/model'
|
||||
import { $layoutTree } from '@/components/pane-shell/tree/store'
|
||||
import { $selectedStoredSessionId } from '@/store/session'
|
||||
import type { SessionTile } from '@/store/session-states'
|
||||
|
|
@ -138,3 +138,92 @@ describe('blankDraftTile', () => {
|
|||
expect(blankDraftTile([], {})).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ⌘⇧T used to only restore `$sessionTiles`. Adoption inserts silently
|
||||
// (activate:false), so the tab came back behind the still-fronted workspace.
|
||||
// Real path: register, adopt, focus — same as paneMirror + reopen.
|
||||
describe('reopenLastClosedTile focuses the restored tab', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
async function setup() {
|
||||
const tree = await import('@/components/pane-shell/tree/store')
|
||||
const model = await import('@/components/pane-shell/tree/model')
|
||||
const { registry } = await import('@/contrib/registry')
|
||||
const session = await import('@/store/session')
|
||||
const states = await import('@/store/session-states')
|
||||
|
||||
registry.register({
|
||||
area: 'panes',
|
||||
data: { placement: 'main', uncloseable: true },
|
||||
id: 'workspace',
|
||||
render: () => null,
|
||||
title: 'chat'
|
||||
})
|
||||
|
||||
// panes ← $sessionTiles (paneMirror stub). Adoption is synchronous on
|
||||
// register, so openSessionTile + focusOpenSession works the same tick.
|
||||
const registered = new Map<string, () => void>()
|
||||
|
||||
const syncTiles = () => {
|
||||
const wanted = new Set(states.$sessionTiles.get().map(t => t.storedSessionId))
|
||||
|
||||
for (const id of wanted) {
|
||||
if (registered.has(id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
registered.set(
|
||||
id,
|
||||
registry.register({
|
||||
area: 'panes',
|
||||
data: { dock: { pane: 'workspace', pos: 'center' }, placement: 'main' },
|
||||
id: tilePane(id),
|
||||
render: () => null,
|
||||
title: id
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
for (const [id, dispose] of registered) {
|
||||
if (!wanted.has(id)) {
|
||||
dispose()
|
||||
registered.delete(id)
|
||||
tree.removeTreePane(tilePane(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
states.$sessionTiles.listen(syncTiles)
|
||||
tree.watchContributedPanes()
|
||||
session.$selectedStoredSessionId.set('primary')
|
||||
tree.declareDefaultTree(model.group(['workspace'], { active: 'workspace', id: 'grp-main' }))
|
||||
|
||||
states.openSessionTile('closed', 'center', 'workspace')
|
||||
states.focusOpenSession('closed')
|
||||
tree.noteActiveTreeGroup('grp-main')
|
||||
expect(findGroupOfPane(tree.$layoutTree.get()!, tilePane('closed'))?.active).toBe(tilePane('closed'))
|
||||
|
||||
return { states, tree }
|
||||
}
|
||||
|
||||
it('fronts the restored tab after ⌘⇧T', async () => {
|
||||
const { states, tree } = await setup()
|
||||
|
||||
states.closeSessionTile('closed')
|
||||
expect(states.$sessionTiles.get().some(t => t.storedSessionId === 'closed')).toBe(false)
|
||||
expect(findGroupOfPane(tree.$layoutTree.get()!, 'workspace')?.active).toBe('workspace')
|
||||
|
||||
states.reopenLastClosedTile()
|
||||
|
||||
expect(states.$sessionTiles.get().some(t => t.storedSessionId === 'closed')).toBe(true)
|
||||
expect(findGroupOfPane(tree.$layoutTree.get()!, tilePane('closed'))?.active).toBe(tilePane('closed'))
|
||||
expect(tree.$activeTreeGroup.get()).toBe('grp-main')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -702,8 +702,10 @@ export function discardSessionTile(storedSessionId: string) {
|
|||
saveTiles($sessionTiles.get().filter(t => t.storedSessionId !== storedSessionId))
|
||||
}
|
||||
|
||||
/** ⌘⇧T — reopen the most recently closed tab where it was. Skips ids that are
|
||||
* live again (reopened, or now the primary). */
|
||||
/** ⌘⇧T — reopen the most recently closed tab where it was, then focus it.
|
||||
* Adoption alone is silent (won't steal the active tab), so restore has to
|
||||
* front the pane explicitly. Skips ids that are live again (reopened / now
|
||||
* the primary). */
|
||||
export function reopenLastClosedTile(): void {
|
||||
const stack = closedStack()
|
||||
|
||||
|
|
@ -716,6 +718,7 @@ export function reopenLastClosedTile(): void {
|
|||
|
||||
if (!$sessionTiles.get().some(t => t.storedSessionId === storedSessionId)) {
|
||||
openSessionTile(storedSessionId, tile.dir, tile.anchor, tile.before)
|
||||
focusOpenSession(storedSessionId)
|
||||
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18239,11 +18239,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
(voice_mode == "all")
|
||||
or (voice_mode == "voice_only" and is_voice_input)
|
||||
# ``voice.auto_tts`` is synced into the adapter on gateway startup.
|
||||
# Treat it as "voice accompanies text replies" unless a chat was
|
||||
# explicitly turned off. The base adapter's own auto-TTS path only
|
||||
# covers voice-input replies, so final text replies need the runner
|
||||
# path here.
|
||||
or (voice_mode != "off" and adapter_auto_tts)
|
||||
# It is the fallback only when the chat has no explicit mode;
|
||||
# otherwise the chat-level all/voice_only/off choice takes precedence.
|
||||
or (voice_mode is None and adapter_auto_tts)
|
||||
)
|
||||
if not should:
|
||||
logger.debug(
|
||||
|
|
|
|||
|
|
@ -27,6 +27,15 @@ A marker only counts as a live update when its pid is alive AND it is younger
|
|||
than :data:`UPDATE_MARKER_MAX_AGE_MS` — mirroring ``readLiveUpdateMarker`` so a
|
||||
crashed updater self-heals instead of wedging every future update. A stale
|
||||
marker is removed on read by whoever notices it first.
|
||||
|
||||
One layering wrinkle: the Tauri updater holds this marker for its WHOLE run and
|
||||
then spawns ``hermes update`` as a child stage. Without a handoff the child
|
||||
sees its own parent's live marker and refuses — the GUI update deadlocks
|
||||
against itself on every attempt ("Hermes is still running", retry forever).
|
||||
The updater therefore exports :data:`HANDOFF_PID_ENV` naming its own pid, and
|
||||
``acquire`` treats a live holder matching that pid as the lock we are already
|
||||
running under. The env var alone grants nothing: the pid must also be the
|
||||
live marker owner, so a stale or forged value cannot bypass the lock.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -47,6 +56,13 @@ UPDATE_MARKER_MAX_AGE_SECONDS = 20 * 60
|
|||
|
||||
MARKER_NAME = ".hermes-update-in-progress"
|
||||
|
||||
# Set by an orchestrating updater (the Tauri `hermes-setup --update` flow) to
|
||||
# its own pid before spawning `hermes update` as a child stage. The parent
|
||||
# holds the marker for its whole run, so without this the child refuses its
|
||||
# own parent's lock and the GUI update can never complete. See update_child_env
|
||||
# in apps/bootstrap-installer/src-tauri/src/update.rs — keep the name in sync.
|
||||
HANDOFF_PID_ENV = "HERMES_UPDATE_HANDOFF_PID"
|
||||
|
||||
# Exit code meaning "another updater/instance owns this install right now".
|
||||
# Already the de-facto contract: the Windows shim + venv-holder guards in
|
||||
# _cmd_update_impl exit 2, and the Tauri updater matches on it
|
||||
|
|
@ -95,6 +111,22 @@ def _pid_alive(pid: int) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _handoff_pid() -> int | None:
|
||||
"""Pid of the orchestrating updater that spawned us, if any.
|
||||
|
||||
Read from :data:`HANDOFF_PID_ENV`. Malformed values count as absent —
|
||||
a broken handoff must fall back to the normal refusal, never crash.
|
||||
"""
|
||||
raw = os.environ.get(HANDOFF_PID_ENV, "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
pid = int(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
return pid if pid > 0 else None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpdateHolder:
|
||||
"""A confirmed-live update currently holding the lock."""
|
||||
|
|
@ -168,9 +200,17 @@ class UpdateLock:
|
|||
self.holder: UpdateHolder | None = None
|
||||
|
||||
def acquire(self) -> bool:
|
||||
"""Claim the lock. Returns False (and sets ``holder``) if it's taken."""
|
||||
"""Claim the lock. Returns False (and sets ``holder``) if it's taken.
|
||||
|
||||
A live holder whose pid matches :data:`HANDOFF_PID_ENV` is our own
|
||||
orchestrating parent (the Tauri updater spawning `hermes update` as a
|
||||
stage): we run under ITS claim rather than refusing or re-writing the
|
||||
marker, and ``release`` leaves the parent's marker untouched.
|
||||
"""
|
||||
existing = read_live_update(path=self.path)
|
||||
if existing is not None:
|
||||
if existing.pid == _handoff_pid():
|
||||
return True
|
||||
self.holder = existing
|
||||
return False
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -73,6 +73,25 @@ class TestAutoVoiceReplyFormat:
|
|||
voice_event, "hello", [], already_sent=True
|
||||
) is True
|
||||
|
||||
def test_should_send_voice_reply_voice_only_still_requires_voice_input(self):
|
||||
"""Explicit voice_only must not widen to text input (#73508 regression).
|
||||
|
||||
Persisted voice_only mode is synced into the adapter as an explicit
|
||||
auto-TTS opt-in, so adapter_auto_tts is True for this chat. The
|
||||
chat-level mode stays authoritative: text input gets no voice reply,
|
||||
voice input still does.
|
||||
"""
|
||||
runner = _make_runner()
|
||||
runner._voice_mode["telegram:123"] = "voice_only"
|
||||
adapter = _make_adapter(Platform.TELEGRAM)
|
||||
adapter._should_auto_tts_for_chat = MagicMock(return_value=True)
|
||||
runner.adapters[Platform.TELEGRAM] = adapter
|
||||
event = _make_event(Platform.TELEGRAM, chat_id="123")
|
||||
|
||||
assert runner._should_send_voice_reply(event, "hello", []) is False
|
||||
|
||||
voice_event = _make_event(Platform.TELEGRAM, chat_id="123", message_type=MessageType.VOICE)
|
||||
assert runner._should_send_voice_reply(voice_event, "hello", [], already_sent=True) is True
|
||||
|
||||
def _make_runner() -> GatewayRunner:
|
||||
with patch("gateway.run.GatewayRunner._load_voice_modes", return_value={}):
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import time
|
|||
import pytest
|
||||
|
||||
from hermes_cli.update_lock import (
|
||||
HANDOFF_PID_ENV,
|
||||
UPDATE_MARKER_MAX_AGE_SECONDS,
|
||||
UpdateLock,
|
||||
describe_holder,
|
||||
|
|
@ -175,3 +176,51 @@ def test_unwritable_marker_location_does_not_block_the_update(tmp_path):
|
|||
|
||||
assert lock.acquire() is True
|
||||
assert lock.acquired is False, "nothing was written, so there is nothing to release"
|
||||
|
||||
|
||||
class TestHandoffFromOrchestratingUpdater:
|
||||
"""The Tauri updater holds the marker, then spawns ``hermes update``.
|
||||
|
||||
The regression: the child saw its own parent's live marker and exited 2,
|
||||
so every GUI update failed with "Hermes is still running" and retrying
|
||||
just re-ran the same self-deadlock. The parent names its pid in
|
||||
HANDOFF_PID_ENV; a live holder matching it is our own orchestrator.
|
||||
"""
|
||||
|
||||
def test_child_runs_under_the_parents_live_claim(self, marker, monkeypatch):
|
||||
# Stand in for the parent updater with our own (live) pid.
|
||||
marker.write_text(f"{os.getpid()}\n{int(time.time())}\n", encoding="utf-8")
|
||||
monkeypatch.setenv(HANDOFF_PID_ENV, str(os.getpid()))
|
||||
|
||||
lock = UpdateLock(path=marker)
|
||||
assert lock.acquire() is True
|
||||
assert lock.acquired is False, "the parent's claim is not ours to own"
|
||||
|
||||
lock.release()
|
||||
assert marker.exists(), "the parent still needs its marker after our stage ends"
|
||||
assert int(marker.read_text(encoding="utf-8").splitlines()[0]) == os.getpid()
|
||||
|
||||
def test_handoff_pid_that_is_not_the_live_holder_grants_nothing(self, marker, monkeypatch):
|
||||
"""The env var alone must not bypass the lock."""
|
||||
marker.write_text(f"{os.getpid()}\n{int(time.time())}\n", encoding="utf-8")
|
||||
monkeypatch.setenv(HANDOFF_PID_ENV, str(os.getpid() + 1))
|
||||
|
||||
lock = UpdateLock(path=marker)
|
||||
assert lock.acquire() is False
|
||||
assert lock.holder is not None
|
||||
|
||||
@pytest.mark.parametrize("value", ["", "not-a-pid", "-1", "0"], ids=["empty", "garbage", "negative", "zero"])
|
||||
def test_malformed_handoff_values_fall_back_to_refusal(self, marker, monkeypatch, value):
|
||||
marker.write_text(f"{os.getpid()}\n{int(time.time())}\n", encoding="utf-8")
|
||||
monkeypatch.setenv(HANDOFF_PID_ENV, value)
|
||||
|
||||
assert UpdateLock(path=marker).acquire() is False
|
||||
|
||||
def test_handoff_env_with_no_marker_claims_normally(self, marker, monkeypatch):
|
||||
"""A handoff pid must not stop us writing our own claim when unlocked."""
|
||||
monkeypatch.setenv(HANDOFF_PID_ENV, str(os.getpid()))
|
||||
|
||||
lock = UpdateLock(path=marker)
|
||||
assert lock.acquire() is True
|
||||
assert lock.acquired is True
|
||||
assert int(marker.read_text(encoding="utf-8").splitlines()[0]) == os.getpid()
|
||||
|
|
|
|||
1
ui-tui/packages/hermes-ink/index.d.ts
vendored
1
ui-tui/packages/hermes-ink/index.d.ts
vendored
|
|
@ -29,6 +29,7 @@ export { default as useStdin } from './src/ink/hooks/use-stdin.ts'
|
|||
export { useTabStatus } from './src/ink/hooks/use-tab-status.ts'
|
||||
export { useTerminalFocus } from './src/ink/hooks/use-terminal-focus.ts'
|
||||
export { useTerminalTitle } from './src/ink/hooks/use-terminal-title.ts'
|
||||
export type { TerminalTitlePair } from './src/ink/hooks/use-terminal-title.ts'
|
||||
export { useTerminalViewport } from './src/ink/hooks/use-terminal-viewport.ts'
|
||||
export { default as measureElement } from './src/ink/measure-element.ts'
|
||||
export { createRoot, forceRedraw, default as render, renderSync } from './src/ink/root.ts'
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export { default as useStdin } from './ink/hooks/use-stdin.js'
|
|||
export { useTabStatus } from './ink/hooks/use-tab-status.js'
|
||||
export { useTerminalFocus } from './ink/hooks/use-terminal-focus.js'
|
||||
export { useTerminalTitle } from './ink/hooks/use-terminal-title.js'
|
||||
export type { TerminalTitlePair } from './ink/hooks/use-terminal-title.js'
|
||||
export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js'
|
||||
export { default as measureElement } from './ink/measure-element.js'
|
||||
export { scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.js'
|
||||
|
|
|
|||
|
|
@ -7,15 +7,20 @@ import { TerminalWriteContext } from '../useTerminalNotification.js'
|
|||
/**
|
||||
* Declaratively set the terminal tab/window title.
|
||||
*
|
||||
* Pass a string to set the title. ANSI escape sequences are stripped
|
||||
* automatically so callers don't need to know about terminal encoding.
|
||||
* Pass a single string to set both the tab and window title (OSC 0).
|
||||
* Pass `{ tab, window }` to set them independently: the short `tab` string
|
||||
* goes to OSC 1 (icon/tab label) and the longer `window` string goes to
|
||||
* OSC 2 (window title bar). This matters for terminals like Apple
|
||||
* Terminal.app whose narrow background tabs truncate the title from the
|
||||
* left — a single long OSC 0 string leaves only the tail visible, while a
|
||||
* separate short OSC 1 keeps the session name readable.
|
||||
*
|
||||
* Pass `null` to opt out — the hook becomes a no-op and leaves the
|
||||
* terminal title untouched.
|
||||
*
|
||||
* On Windows, uses `process.title` (classic conhost doesn't support OSC).
|
||||
* Elsewhere, writes OSC 0 (set title+icon) via Ink's stdout.
|
||||
*/
|
||||
export function useTerminalTitle(title: string | null): void {
|
||||
export function useTerminalTitle(title: string | TerminalTitlePair | null): void {
|
||||
const writeRaw = useContext(TerminalWriteContext)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -23,12 +28,37 @@ export function useTerminalTitle(title: string | null): void {
|
|||
return
|
||||
}
|
||||
|
||||
const clean = stripAnsi(title)
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const clean = stripAnsi(typeof title === 'string' ? title : (title.window ?? title.tab ?? ''))
|
||||
process.title = clean
|
||||
} else {
|
||||
writeRaw(osc(OSC.SET_TITLE_AND_ICON, clean))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof title === 'string') {
|
||||
writeRaw(osc(OSC.SET_TITLE_AND_ICON, stripAnsi(title)))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Separate tab (OSC 1) and window (OSC 2) titles so narrow tab bars
|
||||
// show the short session name instead of a truncated tail.
|
||||
const tab = stripAnsi(title.tab ?? '')
|
||||
const window = stripAnsi(title.window ?? '')
|
||||
|
||||
if (tab && window) {
|
||||
writeRaw(osc(OSC.SET_ICON, tab) + osc(OSC.SET_TITLE, window))
|
||||
} else if (window) {
|
||||
writeRaw(osc(OSC.SET_TITLE_AND_ICON, window))
|
||||
} else if (tab) {
|
||||
writeRaw(osc(OSC.SET_TITLE_AND_ICON, tab))
|
||||
}
|
||||
}, [title, writeRaw])
|
||||
}
|
||||
|
||||
export interface TerminalTitlePair {
|
||||
/** Short title for the tab/icon label (OSC 1). */
|
||||
tab?: string
|
||||
/** Full title for the window title bar (OSC 2). */
|
||||
window?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -626,7 +626,12 @@ export function useMainApp(gw: GatewayClient) {
|
|||
const tabCwd = ui.info?.cwd
|
||||
|
||||
useTerminalTitle(
|
||||
model ? composeTabTitle(marker, ui.sessionTitle, model, tabCwd ? shortCwd(tabCwd, 24) : '') : 'Hermes'
|
||||
model
|
||||
? {
|
||||
tab: composeTabTitle(marker, ui.sessionTitle, '', ''),
|
||||
window: composeTabTitle(marker, ui.sessionTitle, model, tabCwd ? shortCwd(tabCwd, 24) : '')
|
||||
}
|
||||
: 'Hermes'
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
6
ui-tui/src/types/hermes-ink.d.ts
vendored
6
ui-tui/src/types/hermes-ink.d.ts
vendored
|
|
@ -167,7 +167,11 @@ declare module '@hermes/ink' {
|
|||
readonly write: (data: string) => boolean
|
||||
}
|
||||
export function useTerminalFocus(): boolean
|
||||
export function useTerminalTitle(title: string | null): void
|
||||
export function useTerminalTitle(title: string | TerminalTitlePair | null): void
|
||||
export interface TerminalTitlePair {
|
||||
tab?: string
|
||||
window?: string
|
||||
}
|
||||
export function useDeclaredCursor(args: {
|
||||
readonly line: number
|
||||
readonly column: number
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue