desktop: registry-driven slash commands + first-class /resume & /handoff (#42351)
* desktop: surface /tools, /save, /personality and fix /help skill count Move /tools and /save out of TERMINAL_ONLY_COMMANDS and /personality out of ADVANCED_COMMANDS so they appear in the desktop slash palette and execute via the existing slash.exec → command.dispatch fallback. The backend gateway already accepts these through slash.exec (none are in _PENDING_INPUT_COMMANDS or the skill list), so no backend change is required. Recompute skill_count in filterDesktopCommandsCatalog from the filtered pairs. Previously the /help footer echoed the unfiltered backend total — e.g. "60 skill commands available" while only ~29 actually appeared in the rendered list, because the desktop hides terminal-only, picker-owned, and advanced commands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * desktop: keep slash popover live while typing args The trigger regex `(?:^|[\s])([@/])([^\s@/]*)$` stopped matching the moment the user typed a space after a slash command, so the popover never showed arg completions for `/personality`, `/tools`, etc. — even though the backend's `complete.slash` already returns them with a `replace_from` indicator. Split the trigger detection so `/` allows args (`/cmd arg1 arg2`) while `@` keeps the strict no-space behavior. Restrict the slash command name to `[a-zA-Z][\w-]*` so file paths like `src/foo/bar` don't accidentally trigger the popover. Rewrite arg-completion items in useSlashCompletions to insert the full `/personality alice` token instead of stranding `/alice`: when `replace_from` is past the command base, prepend the existing prefix to each item's text so the chip serializer produces a coherent replacement. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * cli: complete toolset names after /tools enable|disable SlashCommandCompleter previously only auto-derived the first subcommand level from args_hint, so `/tools enable <tab>` yielded nothing — the user had to remember every toolset key (web, file, spotify, …) and every MCP server prefix. Add `_tools_completions` that handles both stages: subcommand (list|disable|enable) and tool name. Filter by current enable state so `/tools enable <tab>` only offers disabled toolsets and `/tools disable <tab>` only offers enabled ones — no point suggesting a no-op. MCP server prefixes (server:) come from the saved mcp_servers config; per-tool completion under a server would require runtime MCP introspection and is left as follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * desktop: registry-driven slash commands with first-class pickers Collapse the if/else slash dispatch into one DESKTOP_COMMAND_SPECS table that drives popover suggestions, per-type composer pills, and execution. - /resume, /sessions, /switch: inline session completions (like /skin) plus a "Browse all sessions…" entry that opens a dedicated session picker overlay - /handoff: inline platform completion + handoff.request/handoff.state gateway bridge so desktop reaches CLI parity - colored per-type pills (command/skill/theme) in the composer - strip ANSI and fix width/alignment of slash output in the chat panel * desktop: fold repeated slash session/output boilerplate into one helper runExec, /title, /help and the unavailable case each re-derived the same ensure-session → bail-with-notify → build-renderSlashOutput dance. withSlashOutput() returns {sessionId, render} or null, so each handler is a two-line resolve instead of an eight-line preamble. * desktop: keep backend meta on slash arg completions Arg suggestions (/personality <name>, /tools enable <toolset>, /handoff <platform>) were having their meta overwritten with the parent command's registry description: desktopSlashDescription("/personality none") canonicalizes back to /personality and returns its blurb. Skip the lookup for arg rows so the backend's own display_meta ("clear personality overlay", etc.) survives. * cli: list real personalities in /personality completion _personality_completions resolved load_config().agent.personalities — but that schema has no agent.personalities key, so completion always returned just `none` even though the runtime (load_cli_config().agent.personalities) ships a dozen built-ins (helpful, kawaii, pirate, …). Read from the same source the command actually applies, so `/personality ` surfaces the real options. * desktop: expand bare arg-commands to their options on pick Picking a command like /personality from the slash popover committed it immediately instead of advancing to its argument list. Mark arg-taking commands (/skin, /resume, /handoff, /personality, /tools) in the registry and, when one is picked bare, insert "/cmd " as plain text and re-open the popover on its inline options — mirroring typing "/cmd " by hand. Arg picks (serialized text already contains a space) still commit a single pill. Also realign trigger-popover loading test with the redesigned popover (the /help empty-state hint shows when resolved, not while the spinner is up); the merge from main reintroduced the pre-redesign expectation. * tui_gateway: fold session-db close into a context manager Both handoff RPCs repeated the same `db, close_db = _session_db_handle()` + `finally: if close_db: db.close()` dance. Turn the helper into a `_session_db` contextmanager that owns the close, so callers just `with _session_db(session) as db:`. * desktop: unblock handoff retries and exact resume ids Clear timed-out desktop handoffs through the gateway so retries are not stuck behind a pending row, and let typed /resume session ids bypass the loaded sidebar cache. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
615ad97928
commit
3ffbdfbcc0
@@ -1,6 +1,6 @@
|
||||
import { cleanup, render, waitFor } from '@testing-library/react'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $composerAttachments, type ComposerAttachment } from '@/store/composer'
|
||||
@@ -55,6 +55,7 @@ function Harness({
|
||||
onSeedState,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession,
|
||||
storedSessionId
|
||||
}: {
|
||||
busyRef?: MutableRefObject<boolean>
|
||||
@@ -62,6 +63,7 @@ function Harness({
|
||||
onSeedState?: (state: Record<string, unknown>) => void
|
||||
refreshSessions: () => Promise<void>
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
|
||||
storedSessionId?: null | string
|
||||
}) {
|
||||
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
||||
@@ -69,6 +71,12 @@ function Harness({
|
||||
current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId
|
||||
}
|
||||
const localBusyRef = busyRef ?? { current: false }
|
||||
const stateRef = useRef({
|
||||
messages: [],
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
interrupted: true
|
||||
} as never)
|
||||
|
||||
const actions = usePromptActions({
|
||||
activeSessionId: RUNTIME_SESSION_ID,
|
||||
@@ -79,17 +87,14 @@ function Harness({
|
||||
handleSkinCommand: () => '',
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession: resumeStoredSession ?? (() => undefined),
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft: () => undefined,
|
||||
sttEnabled: false,
|
||||
updateSessionState: (_sessionId, updater) => {
|
||||
// Seed with interrupted:true so we can prove a fresh submit clears it.
|
||||
const next = updater({
|
||||
messages: [],
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
interrupted: true
|
||||
} as never) as unknown as Record<string, unknown>
|
||||
const next = updater(stateRef.current) as unknown as Record<string, unknown>
|
||||
stateRef.current = next as never
|
||||
onSeedState?.(next)
|
||||
|
||||
return next as never
|
||||
@@ -190,6 +195,68 @@ describe('usePromptActions /title', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions desktop slash pickers', () => {
|
||||
beforeEach(() => {
|
||||
setSessions(() => [sessionInfo({ id: '20260610_120000_abcdef', title: 'Loaded session' })])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('resumes an exact session id even when it is not in the loaded sidebar cache', async () => {
|
||||
const resumeStoredSession = vi.fn(async () => undefined)
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
resumeStoredSession={resumeStoredSession}
|
||||
/>
|
||||
)
|
||||
|
||||
await handle!.submitText('/resume 20260610_130000_123abc')
|
||||
|
||||
expect(resumeStoredSession).toHaveBeenCalledWith('20260610_130000_123abc')
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
|
||||
})
|
||||
|
||||
it('marks a timed-out handoff as failed so the next attempt can retry', async () => {
|
||||
vi.useFakeTimers()
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
|
||||
if (method === 'handoff.state') {
|
||||
return { state: 'pending' } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||
|
||||
const result = handle!.submitText('/handoff telegram')
|
||||
await vi.advanceTimersByTimeAsync(61_000)
|
||||
await result
|
||||
|
||||
expect(calls.some(call => call.method === 'handoff.request')).toBe(true)
|
||||
expect(calls).toContainEqual({
|
||||
method: 'handoff.fail',
|
||||
params: {
|
||||
error: expect.stringContaining('Timed out'),
|
||||
session_id: RUNTIME_SESSION_ID
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions submit / queue drain semantics', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
|
||||
@@ -4,20 +4,24 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { getProfiles, transcribeAudio } from '@/hermes'
|
||||
import { translateNow, type Translations, useI18n } from '@/i18n'
|
||||
import { stripAnsi } from '@/lib/ansi'
|
||||
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
||||
import {
|
||||
optimisticAttachmentRef,
|
||||
parseCommandDispatch,
|
||||
parseSlashCommand,
|
||||
pathLabel,
|
||||
sessionTitle,
|
||||
SLASH_COMMAND_RE
|
||||
} from '@/lib/chat-runtime'
|
||||
import {
|
||||
type CommandsCatalogLike,
|
||||
type DesktopActionId,
|
||||
type DesktopPickerId,
|
||||
desktopSlashUnavailableMessage,
|
||||
filterDesktopCommandsCatalog,
|
||||
isDesktopSlashCommand,
|
||||
isModelPickerCommand
|
||||
resolveDesktopCommand
|
||||
} from '@/lib/desktop-slash-commands'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
@@ -38,11 +42,13 @@ import {
|
||||
$busy,
|
||||
$connection,
|
||||
$messages,
|
||||
$sessions,
|
||||
$yoloActive,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setMessages,
|
||||
setModelPickerOpen,
|
||||
setSessionPickerOpen,
|
||||
setSessions,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
@@ -50,12 +56,30 @@ import {
|
||||
import type {
|
||||
ClientSessionState,
|
||||
FileAttachResponse,
|
||||
HandoffFailResponse,
|
||||
HandoffRequestResponse,
|
||||
HandoffStateResponse,
|
||||
ImageAttachResponse,
|
||||
SessionSteerResponse,
|
||||
SessionTitleResponse,
|
||||
SlashExecResponse
|
||||
} from '../../types'
|
||||
|
||||
interface HandoffResult {
|
||||
ok: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function isSessionIdCandidate(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
|
||||
return /^\d{8}_\d{6}_[A-Fa-f0-9]{6}$/.test(trimmed) || /^[A-Fa-f0-9]{32}$/.test(trimmed)
|
||||
}
|
||||
|
||||
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
@@ -245,6 +269,7 @@ interface PromptActionsOptions {
|
||||
handleSkinCommand: (arg: string) => string
|
||||
refreshSessions: () => Promise<void>
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
resumeStoredSession: (storedSessionId: string) => Promise<void> | void
|
||||
selectedStoredSessionIdRef: MutableRefObject<string | null>
|
||||
startFreshSessionDraft: () => void
|
||||
sttEnabled: boolean
|
||||
@@ -260,6 +285,15 @@ interface SubmitTextOptions {
|
||||
fromQueue?: boolean
|
||||
}
|
||||
|
||||
/** Everything a slash handler needs about the invocation it's serving. */
|
||||
interface SlashActionCtx {
|
||||
arg: string
|
||||
command: string
|
||||
name: string
|
||||
recordInput: boolean
|
||||
sessionHint?: string
|
||||
}
|
||||
|
||||
function renderCommandsCatalog(catalog: CommandsCatalogLike, copy: Translations['desktop']): string {
|
||||
const desktopCatalog = filterDesktopCommandsCatalog(catalog)
|
||||
|
||||
@@ -310,6 +344,7 @@ export function usePromptActions({
|
||||
handleSkinCommand,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
sttEnabled,
|
||||
@@ -320,7 +355,11 @@ export function usePromptActions({
|
||||
|
||||
const appendSessionTextMessage = useCallback(
|
||||
(sessionId: string, role: ChatMessage['role'], text: string) => {
|
||||
const body = text.trim()
|
||||
// Strip ANSI: slash-command output from the backend worker carries SGR
|
||||
// color codes (e.g. "Unknown command" in red). The ESC byte is invisible
|
||||
// in the chat panel, so without this the `[1;31m…[0m` payload leaks as
|
||||
// literal text.
|
||||
const body = stripAnsi(text).trim()
|
||||
|
||||
if (!body) {
|
||||
return
|
||||
@@ -696,230 +735,124 @@ export function usePromptActions({
|
||||
]
|
||||
)
|
||||
|
||||
// Queue a handoff of this session to a messaging platform and watch it to
|
||||
// a terminal state. We only write the request through the gateway; the
|
||||
// separate `hermes gateway` process performs the actual transfer, so we
|
||||
// poll `handoff.state` (mirror of the CLI's block-poll) for the result.
|
||||
const handoffSession = useCallback(
|
||||
async (
|
||||
platform: string,
|
||||
options?: { onProgress?: (state: string) => void; sessionId?: string }
|
||||
): Promise<HandoffResult> => {
|
||||
const sid = options?.sessionId || activeSessionIdRef.current
|
||||
|
||||
if (!sid) {
|
||||
return { error: copy.sessionUnavailable, ok: false }
|
||||
}
|
||||
|
||||
const target = platform.trim().toLowerCase()
|
||||
|
||||
if (!target) {
|
||||
return { error: copy.handoff.failed(''), ok: false }
|
||||
}
|
||||
|
||||
try {
|
||||
options?.onProgress?.('pending')
|
||||
await requestGateway<HandoffRequestResponse>('handoff.request', {
|
||||
platform: target,
|
||||
session_id: sid
|
||||
})
|
||||
} catch (err) {
|
||||
return { error: inlineErrorMessage(err, copy.handoff.failed(target)), ok: false }
|
||||
}
|
||||
|
||||
const deadline = Date.now() + 60_000
|
||||
let lastState = 'pending'
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await delay(800)
|
||||
|
||||
let record: HandoffStateResponse
|
||||
|
||||
try {
|
||||
record = await requestGateway<HandoffStateResponse>('handoff.state', { session_id: sid })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const state = record.state || 'pending'
|
||||
|
||||
if (state !== lastState) {
|
||||
options?.onProgress?.(state)
|
||||
lastState = state
|
||||
}
|
||||
|
||||
if (state === 'completed') {
|
||||
appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
|
||||
notify({ kind: 'success', message: copy.handoff.success(target) })
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
if (state === 'failed') {
|
||||
return { error: record.error || copy.handoff.failed(target), ok: false }
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = await requestGateway<HandoffFailResponse>('handoff.fail', {
|
||||
error: copy.handoff.timedOut,
|
||||
session_id: sid
|
||||
}).catch(() => null)
|
||||
|
||||
if (cleanup?.state === 'completed') {
|
||||
appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
|
||||
notify({ kind: 'success', message: copy.handoff.success(target) })
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
return { error: copy.handoff.timedOut, ok: false }
|
||||
},
|
||||
[activeSessionIdRef, appendSessionTextMessage, copy, requestGateway]
|
||||
)
|
||||
|
||||
const executeSlashCommand = useCallback(
|
||||
async (rawCommand: string, options?: { sessionId?: string; recordInput?: boolean }) => {
|
||||
const runSlash = async (commandText: string, sessionHint?: string, recordInput = true): Promise<void> => {
|
||||
const command = commandText.trim()
|
||||
const { name, arg } = parseSlashCommand(command)
|
||||
const normalizedName = name.toLowerCase()
|
||||
const ensureSessionId = async (sessionHint?: string) =>
|
||||
sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
|
||||
|
||||
if (!name) {
|
||||
const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
|
||||
|
||||
if (sessionId) {
|
||||
appendSessionTextMessage(sessionId, 'system', copy.emptySlashCommand)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizedName === 'new' || normalizedName === 'reset') {
|
||||
startFreshSessionDraft()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizedName === 'branch' || normalizedName === 'fork') {
|
||||
await branchCurrentSession()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// /yolo maps to the status-bar YOLO control — a per-session approval
|
||||
// bypass, same scope as the TUI's Shift+Tab. With no session yet we arm
|
||||
// it locally; the session-create path applies it on the first message.
|
||||
if (normalizedName === 'yolo') {
|
||||
const sid = sessionHint || activeSessionIdRef.current
|
||||
const next = !$yoloActive.get()
|
||||
|
||||
if (!sid) {
|
||||
setYoloActive(next)
|
||||
notify({ kind: 'success', message: next ? copy.yoloArmed : copy.yoloOff })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const active = await setSessionYolo(requestGateway, sid, next)
|
||||
appendSessionTextMessage(sid, 'system', copy.yoloSystem(active))
|
||||
} catch {
|
||||
notify({ kind: 'error', title: copy.yoloTitle, message: copy.yoloToggleFailed })
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// /model opens the desktop model picker overlay — the same full
|
||||
// provider+model picker reachable from the status-bar model button —
|
||||
// instead of the headless prompt_toolkit modal the slash worker can't
|
||||
// render. With explicit args (`/model <name> [--provider ...]`) run the
|
||||
// switch directly through slash.exec so power users can still type it.
|
||||
if (isModelPickerCommand(`/${normalizedName}`)) {
|
||||
if (!arg.trim()) {
|
||||
setModelPickerOpen(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const sid = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
|
||||
|
||||
if (!sid) {
|
||||
notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await requestGateway<SlashExecResponse>('slash.exec', {
|
||||
session_id: sid,
|
||||
command: command.replace(/^\/+/, '')
|
||||
})
|
||||
|
||||
const body = result?.output || `/${name}: model switched`
|
||||
appendSessionTextMessage(
|
||||
sid,
|
||||
'system',
|
||||
recordInput ? slashStatusText(command, body) : body
|
||||
)
|
||||
} catch (err) {
|
||||
appendSessionTextMessage(
|
||||
sid,
|
||||
'system',
|
||||
`error: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizedName === 'skin' && !sessionHint && !activeSessionIdRef.current) {
|
||||
notify({ kind: 'success', message: handleSkinCommand(arg) })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// /profile selects which profile new chats open in — no app relaunch.
|
||||
// A profile is per-session now, so an existing thread can't change its
|
||||
// profile mid-stream; `/profile <name>` instead points the next new chat
|
||||
// (and the current empty draft) at that profile's backend.
|
||||
if (normalizedName === 'profile') {
|
||||
const target = arg.trim()
|
||||
const current = normalizeProfileKey($activeGatewayProfile.get())
|
||||
|
||||
if (!target) {
|
||||
notify({
|
||||
kind: 'success',
|
||||
message: copy.profileStatus(current)
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { profiles } = await getProfiles()
|
||||
const match = profiles.find(profile => profile.name === target)
|
||||
|
||||
if (!match) {
|
||||
notify({
|
||||
kind: 'error',
|
||||
title: copy.unknownProfile,
|
||||
message: copy.noProfileNamed(target, profiles.map(profile => profile.name).join(', '))
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const key = normalizeProfileKey(match.name)
|
||||
|
||||
$newChatProfile.set(key)
|
||||
// Swap the live gateway now so an empty draft sends into this
|
||||
// profile immediately; an existing thread keeps its own profile.
|
||||
await ensureGatewayProfile(key)
|
||||
notify({ kind: 'success', message: copy.newChatsProfile(match.name) })
|
||||
} catch (err) {
|
||||
notifyError(err, copy.setProfileFailed)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
|
||||
// Resolve the target session plus a writer for inline slash output, or
|
||||
// notify + return null when none can be created. Folds the ensure / bail /
|
||||
// build-renderSlashOutput boilerplate every exec-style handler repeats.
|
||||
const withSlashOutput = async (
|
||||
ctx: SlashActionCtx
|
||||
): Promise<{ render: (text: string) => void; sessionId: string } | null> => {
|
||||
const sessionId = await ensureSessionId(ctx.sessionHint)
|
||||
|
||||
if (!sessionId) {
|
||||
notify({
|
||||
kind: 'error',
|
||||
title: copy.sessionUnavailable,
|
||||
message: copy.createSessionFailed
|
||||
})
|
||||
notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const render = (text: string) =>
|
||||
appendSessionTextMessage(sessionId, 'system', ctx.recordInput ? slashStatusText(ctx.command, text) : text)
|
||||
|
||||
return { render, sessionId }
|
||||
}
|
||||
|
||||
// `exec` commands (and unknown skill / quick commands the backend owns)
|
||||
// run on the gateway and render their text output inline. This is the only
|
||||
// path that talks to slash.exec / command.dispatch.
|
||||
async function runExec(ctx: SlashActionCtx): Promise<void> {
|
||||
const { arg, command, name } = ctx
|
||||
const resolved = await withSlashOutput(ctx)
|
||||
|
||||
if (!resolved) {
|
||||
return
|
||||
}
|
||||
|
||||
const renderSlashOutput = (text: string) =>
|
||||
appendSessionTextMessage(sessionId, 'system', recordInput ? slashStatusText(command, text) : text)
|
||||
|
||||
// /title <name> renames the session. Route through the gateway's
|
||||
// `session.title` RPC — the same path the TUI uses — NOT the REST
|
||||
// renameSession endpoint and NOT the slash worker.
|
||||
//
|
||||
// Why not the slash worker: it's a separate HermesCLI subprocess whose
|
||||
// SQLite write to the shared state.db can silently fail (notably on
|
||||
// Windows), and it never refreshes the sidebar.
|
||||
//
|
||||
// Why not REST renameSession: `sessionId` here is the *runtime* session
|
||||
// id returned by session.create — it is NOT the stored DB `sessions.id`,
|
||||
// and session.create deliberately does not persist a DB row until the
|
||||
// first turn. The REST PATCH endpoint resolves against the sessions
|
||||
// table, so a runtime id (or a brand-new, not-yet-persisted session)
|
||||
// 404s with "Session not found" on every platform. See #38508 / #38576.
|
||||
//
|
||||
// session.title maps the runtime id to the in-memory session, writes
|
||||
// through the gateway's own DB connection, and QUEUES the title
|
||||
// (`pending: true`) when the row isn't persisted yet — so it works for a
|
||||
// fresh chat too. refreshSessions() then pulls the authoritative title
|
||||
// back into the sidebar. A bare `/title` (no arg) still falls through to
|
||||
// the worker to display the current title.
|
||||
if (normalizedName === 'title' && arg) {
|
||||
try {
|
||||
const result = await requestGateway<SessionTitleResponse>('session.title', {
|
||||
session_id: sessionId,
|
||||
title: arg
|
||||
})
|
||||
|
||||
const finalTitle = (result?.title || arg).trim()
|
||||
const queued = result?.pending === true
|
||||
|
||||
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
|
||||
await refreshSessions().catch(() => undefined)
|
||||
renderSlashOutput(
|
||||
finalTitle
|
||||
? `Session title set: ${finalTitle}${queued ? ' (queued while session initializes)' : ''}`
|
||||
: 'Session title cleared.'
|
||||
)
|
||||
} catch (err) {
|
||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizedName === 'skin') {
|
||||
renderSlashOutput(handleSkinCommand(arg))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (name === 'help' || name === 'commands') {
|
||||
try {
|
||||
const catalog = await requestGateway<CommandsCatalogLike>('commands.catalog', { session_id: sessionId })
|
||||
|
||||
renderSlashOutput(renderCommandsCatalog(catalog, copy))
|
||||
} catch (err) {
|
||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
const { render: renderSlashOutput, sessionId } = resolved
|
||||
|
||||
if (!isDesktopSlashCommand(name)) {
|
||||
renderSlashOutput(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`)
|
||||
@@ -943,11 +876,7 @@ export function usePromptActions({
|
||||
|
||||
try {
|
||||
const dispatch = parseCommandDispatch(
|
||||
await requestGateway<unknown>('command.dispatch', {
|
||||
session_id: sessionId,
|
||||
name,
|
||||
arg
|
||||
})
|
||||
await requestGateway<unknown>('command.dispatch', { session_id: sessionId, name, arg })
|
||||
)
|
||||
|
||||
if (!dispatch) {
|
||||
@@ -994,6 +923,261 @@ export function usePromptActions({
|
||||
}
|
||||
}
|
||||
|
||||
// One handler per `action` command. Adding a desktop-native command is a
|
||||
// registry row in desktop-slash-commands.ts plus an entry here — never a
|
||||
// new branch in a dispatch ladder.
|
||||
const actionHandlers: Record<DesktopActionId, (ctx: SlashActionCtx) => Promise<void>> = {
|
||||
new: async () => {
|
||||
startFreshSessionDraft()
|
||||
},
|
||||
branch: async () => {
|
||||
await branchCurrentSession()
|
||||
},
|
||||
// /yolo maps to the status-bar YOLO control — a per-session approval
|
||||
// bypass, same scope as the TUI's Shift+Tab. With no session yet we arm
|
||||
// it locally; the session-create path applies it on the first message.
|
||||
yolo: async ({ sessionHint }) => {
|
||||
const sid = sessionHint || activeSessionIdRef.current
|
||||
const next = !$yoloActive.get()
|
||||
|
||||
if (!sid) {
|
||||
setYoloActive(next)
|
||||
notify({ kind: 'success', message: next ? copy.yoloArmed : copy.yoloOff })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const active = await setSessionYolo(requestGateway, sid, next)
|
||||
appendSessionTextMessage(sid, 'system', copy.yoloSystem(active))
|
||||
} catch {
|
||||
notify({ kind: 'error', title: copy.yoloTitle, message: copy.yoloToggleFailed })
|
||||
}
|
||||
},
|
||||
// /handoff hands this session to a messaging platform. The platform is
|
||||
// completed inline in the slash popover (backend _handoff_completions),
|
||||
// so there is no overlay: `/handoff <platform>` runs the desktop's own
|
||||
// handoff RPC. cli_only on the backend, so it must not reach slash.exec.
|
||||
handoff: async ({ arg, command, recordInput, sessionHint }) => {
|
||||
const platform = arg.trim()
|
||||
|
||||
if (!platform) {
|
||||
notify({ kind: 'success', message: copy.handoff.pickPlatform })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const sid = sessionHint || activeSessionIdRef.current
|
||||
|
||||
if (!sid) {
|
||||
notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const result = await handoffSession(platform, { sessionId: sid })
|
||||
|
||||
if (!result.ok && result.error) {
|
||||
appendSessionTextMessage(sid, 'system', recordInput ? slashStatusText(command, result.error) : result.error)
|
||||
}
|
||||
},
|
||||
// /profile selects which profile new chats open in — no app relaunch.
|
||||
// A profile is per-session now, so an existing thread can't change its
|
||||
// profile mid-stream; `/profile <name>` points the next new chat (and
|
||||
// the current empty draft) at that profile's backend.
|
||||
profile: async ({ arg }) => {
|
||||
const target = arg.trim()
|
||||
const current = normalizeProfileKey($activeGatewayProfile.get())
|
||||
|
||||
if (!target) {
|
||||
notify({ kind: 'success', message: copy.profileStatus(current) })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { profiles } = await getProfiles()
|
||||
const match = profiles.find(profile => profile.name === target)
|
||||
|
||||
if (!match) {
|
||||
notify({
|
||||
kind: 'error',
|
||||
title: copy.unknownProfile,
|
||||
message: copy.noProfileNamed(target, profiles.map(profile => profile.name).join(', '))
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const key = normalizeProfileKey(match.name)
|
||||
|
||||
$newChatProfile.set(key)
|
||||
await ensureGatewayProfile(key)
|
||||
notify({ kind: 'success', message: copy.newChatsProfile(match.name) })
|
||||
} catch (err) {
|
||||
notifyError(err, copy.setProfileFailed)
|
||||
}
|
||||
},
|
||||
skin: async ({ arg, command, recordInput, sessionHint }) => {
|
||||
const sid = sessionHint || activeSessionIdRef.current
|
||||
const message = handleSkinCommand(arg)
|
||||
|
||||
// No session to print into yet — surface it as a toast instead of
|
||||
// spinning up a backend session just to change the theme.
|
||||
if (!sid) {
|
||||
notify({ kind: 'success', message })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
appendSessionTextMessage(sid, 'system', recordInput ? slashStatusText(command, message) : message)
|
||||
},
|
||||
// /title <name> renames via the gateway's session.title RPC — the same
|
||||
// path the TUI uses, NOT REST renameSession (which 404s on runtime ids)
|
||||
// nor the slash worker (whose DB write can silently fail). Bare /title
|
||||
// shows the current title, which the worker owns, so delegate to exec.
|
||||
title: async ctx => {
|
||||
if (!ctx.arg) {
|
||||
await runExec(ctx)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const resolved = await withSlashOutput(ctx)
|
||||
|
||||
if (!resolved) {
|
||||
return
|
||||
}
|
||||
|
||||
const { render: renderSlashOutput, sessionId } = resolved
|
||||
const { arg } = ctx
|
||||
|
||||
try {
|
||||
const result = await requestGateway<SessionTitleResponse>('session.title', {
|
||||
session_id: sessionId,
|
||||
title: arg
|
||||
})
|
||||
|
||||
const finalTitle = (result?.title || arg).trim()
|
||||
const queued = result?.pending === true
|
||||
|
||||
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
|
||||
await refreshSessions().catch(() => undefined)
|
||||
renderSlashOutput(
|
||||
finalTitle
|
||||
? `Session title set: ${finalTitle}${queued ? ' (queued while session initializes)' : ''}`
|
||||
: 'Session title cleared.'
|
||||
)
|
||||
} catch (err) {
|
||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
},
|
||||
help: async ctx => {
|
||||
const resolved = await withSlashOutput(ctx)
|
||||
|
||||
if (!resolved) {
|
||||
return
|
||||
}
|
||||
|
||||
const { render: renderSlashOutput, sessionId } = resolved
|
||||
|
||||
try {
|
||||
const catalog = await requestGateway<CommandsCatalogLike>('commands.catalog', { session_id: sessionId })
|
||||
|
||||
renderSlashOutput(renderCommandsCatalog(catalog, copy))
|
||||
} catch (err) {
|
||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Picker commands open a desktop overlay; a typed arg is resolved by that
|
||||
// picker so the command never dead-ends or falls through to the backend.
|
||||
const openPicker = async (pickerId: DesktopPickerId, ctx: SlashActionCtx): Promise<void> => {
|
||||
if (pickerId === 'model') {
|
||||
if (!ctx.arg.trim()) {
|
||||
setModelPickerOpen(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Power users can still type `/model <name>` — run it on the backend.
|
||||
await runExec(ctx)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// session picker — /resume, /sessions, /switch
|
||||
const query = ctx.arg.trim()
|
||||
|
||||
if (!query) {
|
||||
setSessionPickerOpen(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const sessions = $sessions.get()
|
||||
const lower = query.toLowerCase()
|
||||
|
||||
const match =
|
||||
sessions.find(session => session.id === query) ||
|
||||
sessions.find(session => sessionTitle(session).toLowerCase().includes(lower)) ||
|
||||
sessions.find(session => (session.preview ?? '').toLowerCase().includes(lower))
|
||||
|
||||
if (!match) {
|
||||
if (isSessionIdCandidate(query)) {
|
||||
await resumeStoredSession(query)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
notify({ kind: 'error', message: copy.resumeFailed })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await resumeStoredSession(match.id)
|
||||
}
|
||||
|
||||
// The whole dispatcher: resolve the command's desktop surface, then act on
|
||||
// its kind. No per-command ladder — behavior lives in the registry.
|
||||
async function runSlash(commandText: string, sessionHint?: string, recordInput = true): Promise<void> {
|
||||
const command = commandText.trim()
|
||||
const { name, arg } = parseSlashCommand(command)
|
||||
|
||||
if (!name) {
|
||||
const sessionId = await ensureSessionId(sessionHint)
|
||||
|
||||
if (sessionId) {
|
||||
appendSessionTextMessage(sessionId, 'system', copy.emptySlashCommand)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const ctx: SlashActionCtx = { arg, command, name, recordInput, sessionHint }
|
||||
const surface = resolveDesktopCommand(`/${name}`)?.surface
|
||||
|
||||
switch (surface?.kind) {
|
||||
case 'unavailable': {
|
||||
const resolved = await withSlashOutput(ctx)
|
||||
resolved?.render(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
case 'picker':
|
||||
return openPicker(surface.picker, ctx)
|
||||
|
||||
case 'action':
|
||||
return actionHandlers[surface.action](ctx)
|
||||
|
||||
default:
|
||||
// exec spec, or an unknown skill / quick command the backend owns.
|
||||
return runExec(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
await runSlash(rawCommand, options?.sessionId, options?.recordInput ?? true)
|
||||
},
|
||||
[
|
||||
@@ -1004,8 +1188,10 @@ export function usePromptActions({
|
||||
copy,
|
||||
createBackendSessionForSend,
|
||||
handleSkinCommand,
|
||||
handoffSession,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession,
|
||||
startFreshSessionDraft,
|
||||
submitPromptText
|
||||
]
|
||||
@@ -1314,6 +1500,7 @@ export function usePromptActions({
|
||||
cancelRun,
|
||||
editMessage,
|
||||
handleThreadMessagesChange,
|
||||
handoffSession,
|
||||
reloadFromMessage,
|
||||
steerPrompt,
|
||||
submitText,
|
||||
|
||||
Reference in New Issue
Block a user