diff --git a/apps/bootstrap-installer/src-tauri/src/update.rs b/apps/bootstrap-installer/src-tauri/src/update.rs index 63a5bfe8d71..3fada182d02 100644 --- a/apps/bootstrap-installer/src-tauri/src/update.rs +++ b/apps/bootstrap-installer/src-tauri/src/update.rs @@ -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"); diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 344b149d0d1..121de24f698 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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 ? { diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index b82a6e1484b..8822efd2a99 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -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) diff --git a/apps/desktop/scripts/probe-command-palette.mjs b/apps/desktop/scripts/probe-command-palette.mjs new file mode 100644 index 00000000000..1b48d81df8a --- /dev/null +++ b/apps/desktop/scripts/probe-command-palette.mjs @@ -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() diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts index d981d6435d8..82464ffd5d4 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -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) { diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index dbcef7bfe28..19b71ed6543 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -1043,9 +1043,7 @@ export function ChatBar({
- {isHelpHint && } - {trigger && !argStageEmpty && ( - - )} - {!poppedOut && ( -
- )} - {/* Drag region: covers the transparent grab margin around the surface. + {isHelpHint && } + {trigger && !argStageEmpty && ( + + )} + {!poppedOut && ( +
+ )} + {/* 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 && ( -
- )} -
-
+ {popoutAllowed && (
- + )} +
- {/* Contribution seams: banners above, a row below, inline - additions beside the "+" menu and before the controls. - All four render nothing until something contributes. */} - - - - {queueEdit && editingQueuedPrompt && ( -
-
- {t.composer.editingQueuedInComposer} -
-
- - -
-
- )} - {attachments.length > 0 && } +
+
-
- {contextMenu} - -
-
{input}
-
- - {controls} + {/* Contribution seams: banners above, a row below, inline + additions beside the "+" menu and before the controls. + All four render nothing until something contributes. */} + + + + {queueEdit && editingQueuedPrompt && ( +
+
+ {t.composer.editingQueuedInComposer} +
+
+ + +
+
+ )} + {attachments.length > 0 && } +
+
+ {contextMenu} + +
+
{input}
+
+ + {controls} +
+
-
-
{/* Underside: chrome-free strip BELOW the composer. Outside the root for the same reason as the micro actions — it must not fall inside diff --git a/apps/desktop/src/app/chat/composer/rich-editor.test.ts b/apps/desktop/src/app/chat/composer/rich-editor.test.ts index e55f24e8cf1..6c3d2f87332 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.test.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.test.ts @@ -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', () => { diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index bc6a1f26bc8..9958c3a4b09 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } 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
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) diff --git a/apps/desktop/src/app/chat/composer/slash-refs.test.ts b/apps/desktop/src/app/chat/composer/slash-refs.test.ts new file mode 100644 index 00000000000..2b50d750875 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/slash-refs.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' + +import { slashCommandMatches } from './slash-refs' + +const commands = (text: string, options?: Parameters[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([]) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/slash-refs.ts b/apps/desktop/src/app/chat/composer/slash-refs.ts new file mode 100644 index 00000000000..db0cf42adb6 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/slash-refs.ts @@ -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 +} diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts index 19dfa8ce0d9..3224716b27c 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.ts @@ -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 { diff --git a/apps/desktop/src/app/chat/sidebar/chrome.tsx b/apps/desktop/src/app/chat/sidebar/chrome.tsx index 196c71768a9..8e2da487829 100644 --- a/apps/desktop/src/app/chat/sidebar/chrome.tsx +++ b/apps/desktop/src/app/chat/sidebar/chrome.tsx @@ -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 {children} + return {children} } // ── Row geometry (session row is canonical — everything composes these) ───── diff --git a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx index 21c43cd6c7c..dd6988ce916 100644 --- a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx @@ -135,7 +135,7 @@ export function SidebarCronJobsSection({
) : ( -
{labelBody}
+
{labelBody}
)} {action}
diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 970f4fb4be2..d62e33b6ffe 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -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 + 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 && ( +
{noResultsLabel}
+ )} + {deferred.map((group, index) => ( + + {group.items.map(item => ( + + ))} + + ))} + + ) +}) + const PaletteRow = memo(function PaletteRow({ bindings, item, + modHeld, onSelectMods, onSelectItem, search }: { bindings: Record 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 ( - - {/* Same per-term split as scoreItem's AND matcher, so the emphasis - shows exactly which words earned the row its rank. */} - + + {modPreview ? ( + item.modLabel + ) : ( + /* Same per-term split as scoreItem's AND matcher, so the emphasis + shows exactly which words earned the row its rank. */ + + )} {item.detail && ( {item.detail} )} - {combo && } + {combo && ( + + )} {item.to && } {item.active && } @@ -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>['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 ( + + {mounted && } + + ) +} + +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 ". 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 ( - - - {/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */} - - + {/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */} + + { + if (event.target === event.currentTarget && event.currentTarget.dataset.state === 'closed') { + onExited() + } + }} + > + {t.commandCenter.paletteTitle} + + {activePage && ( + )} - > - {t.commandCenter.paletteTitle} - - {activePage && ( - + { + // 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' ? : undefined} + value={search} + /> + + {/* Server-driven pages render their own list; the rest show groups. */} + {page === 'pets' ? ( + { + closeCommandPalette() + openPetGenerate() + }} + search={search} + /> + ) : page === 'install-theme' ? ( + + ) : ( + )} - { - // 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' ? : undefined} - value={search} - /> - - {/* Server-driven pages render their own list; the rest show groups. */} - {page === 'pets' ? ( - { - closeCommandPalette() - openPetGenerate() - }} - search={search} - /> - ) : page === 'install-theme' ? ( - - ) : ( - <> - {/* Filtering happens in rankGroups, so cmdk's own CommandEmpty - (keyed to its internal filter count) would never fire. */} - {visibleGroups.length === 0 && ( -
{t.commandCenter.noResults}
- )} - {visibleGroups.map((group, index) => ( - - {group.items.map(item => ( - - ))} - - ))} - - )} -
-
-
-
-
+ + + + ) } diff --git a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts index d64a3a6fe79..cff5d86f4d5 100644 --- a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts +++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts @@ -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()) { diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 559ccc90e0b..44d35607e10 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -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' }) diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 9012cc9c110..1b4d535fa48 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -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, diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index 019328d21c5..2c0913fb6dc 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -100,16 +100,8 @@ const _chatMessageFieldsExhaustive: { [K in Exclude]: 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) ) } diff --git a/apps/desktop/src/app/settings/keybind-settings.tsx b/apps/desktop/src/app/settings/keybind-settings.tsx index 0e6b69ee36a..a974de48882 100644 --- a/apps/desktop/src/app/settings/keybind-settings.tsx +++ b/apps/desktop/src/app/settings/keybind-settings.tsx @@ -173,11 +173,13 @@ export function KeybindSettings() { function CategoryHeader({ label, onToggle, open }: { label: string; onToggle: () => void; open: boolean }) { return (