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({