diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 020455b33..98879bcee 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -506,6 +506,7 @@ export function DesktopController() { busyRef, createBackendSessionForSend, handleSkinCommand, + refreshSessions, requestGateway, selectedStoredSessionIdRef, startFreshSessionDraft, diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx new file mode 100644 index 000000000..a27bd2cbd --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx @@ -0,0 +1,166 @@ +import { cleanup, render } from '@testing-library/react' +import type { MutableRefObject } from 'react' +import { useEffect } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $sessions, setSessions } from '@/store/session' +import type { SessionInfo } from '@/types/hermes' + +import { usePromptActions } from './use-prompt-actions' + +vi.mock('@/hermes', () => ({ + transcribeAudio: vi.fn() +})) + +// The active id the desktop holds is the *runtime* session id from +// session.create — deliberately distinct from the stored DB id here, because +// that mismatch is the bug: the REST renameSession endpoint resolves against +// the stored sessions table and 404s on a runtime id. session.title accepts +// the runtime id directly. +const RUNTIME_SESSION_ID = 'rt-abc123' + +function sessionInfo(overrides: Partial = {}): SessionInfo { + return { + ended_at: null, + id: RUNTIME_SESSION_ID, + input_tokens: 0, + is_active: true, + last_active: 0, + message_count: 3, + model: null, + output_tokens: 0, + preview: null, + source: null, + started_at: 0, + title: 'Old title', + tool_call_count: 0, + ...overrides + } +} + +interface HarnessHandle { + submitText: (text: string) => Promise +} + +function Harness({ + onReady, + refreshSessions, + requestGateway +}: { + onReady: (handle: HarnessHandle) => void + refreshSessions: () => Promise + requestGateway: (method: string, params?: Record) => Promise +}) { + const activeSessionIdRef: MutableRefObject = { current: RUNTIME_SESSION_ID } + const selectedStoredSessionIdRef: MutableRefObject = { current: RUNTIME_SESSION_ID } + const busyRef = { current: false } + + const actions = usePromptActions({ + activeSessionId: RUNTIME_SESSION_ID, + activeSessionIdRef, + branchCurrentSession: async () => true, + busyRef, + createBackendSessionForSend: async () => RUNTIME_SESSION_ID, + handleSkinCommand: () => '', + refreshSessions, + requestGateway, + selectedStoredSessionIdRef, + startFreshSessionDraft: () => undefined, + sttEnabled: false, + updateSessionState: (_sessionId, updater) => + updater({ messages: [], busy: false, awaitingResponse: false } as never) + }) + + useEffect(() => { + onReady({ submitText: actions.submitText }) + }, [actions.submitText, onReady]) + + return null +} + +describe('usePromptActions /title', () => { + beforeEach(() => { + setSessions(() => [sessionInfo()]) + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('renames via the session.title RPC (with the runtime id), updates the sidebar store, and refreshes', async () => { + const refreshSessions = vi.fn(async () => undefined) + const requestGateway = vi.fn(async (method: string) => + (method === 'session.title' ? { pending: false, title: 'New title' } : {}) as never + ) + + let handle: HarnessHandle | null = null + render( (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />) + + await handle!.submitText('/title New title') + + // Routes through session.title with the runtime session id — NOT the slash + // worker (slash.exec) and NOT the REST endpoint. This is the path that + // resolves the runtime id and persists reliably across platforms. + expect(requestGateway).toHaveBeenCalledWith('session.title', { + session_id: RUNTIME_SESSION_ID, + title: 'New title' + }) + expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything()) + expect(refreshSessions).toHaveBeenCalledTimes(1) + expect($sessions.get()[0]?.title).toBe('New title') + }) + + it('reports the queued state when the session row is not persisted yet', async () => { + const refreshSessions = vi.fn(async () => undefined) + const requestGateway = vi.fn(async (method: string) => + (method === 'session.title' ? { pending: true, title: 'Fresh chat' } : {}) as never + ) + + let handle: HarnessHandle | null = null + render( (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />) + + await handle!.submitText('/title Fresh chat') + + expect(requestGateway).toHaveBeenCalledWith('session.title', { + session_id: RUNTIME_SESSION_ID, + title: 'Fresh chat' + }) + // Even when queued, the sidebar reflects the chosen title optimistically. + expect(refreshSessions).toHaveBeenCalledTimes(1) + expect($sessions.get()[0]?.title).toBe('Fresh chat') + }) + + it('falls through to the slash worker for a bare /title (show current title)', async () => { + const refreshSessions = vi.fn(async () => undefined) + const requestGateway = vi.fn(async () => ({ output: 'Title: Old title' }) as never) + + let handle: HarnessHandle | null = null + render( (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />) + + await handle!.submitText('/title') + + expect(requestGateway).not.toHaveBeenCalledWith('session.title', expect.anything()) + expect(requestGateway).toHaveBeenCalledWith('slash.exec', expect.objectContaining({ command: 'title' })) + }) + + it('surfaces a rename error without touching the sidebar store', async () => { + const refreshSessions = vi.fn(async () => undefined) + const requestGateway = vi.fn(async (method: string) => { + if (method === 'session.title') { + throw new Error('Title too long') + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />) + + await handle!.submitText('/title way too long title') + + expect(requestGateway).toHaveBeenCalledWith('session.title', expect.objectContaining({ title: 'way too long title' })) + expect(refreshSessions).not.toHaveBeenCalled() + expect($sessions.get()[0]?.title).toBe('Old title') + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index 535010f48..9abd79743 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -37,10 +37,11 @@ import { setAwaitingResponse, setBusy, setMessages, + setSessions, setYoloActive } from '@/store/session' -import type { ClientSessionState, ImageAttachResponse, SlashExecResponse } from '../../types' +import type { ClientSessionState, ImageAttachResponse, SessionTitleResponse, SlashExecResponse } from '../../types' function blobToDataUrl(blob: Blob): Promise { return new Promise((resolve, reject) => { @@ -77,6 +78,7 @@ interface PromptActionsOptions { branchCurrentSession: () => Promise createBackendSessionForSend: (preview?: string | null) => Promise handleSkinCommand: (arg: string) => string + refreshSessions: () => Promise requestGateway: (method: string, params?: Record) => Promise selectedStoredSessionIdRef: MutableRefObject startFreshSessionDraft: () => void @@ -141,6 +143,7 @@ export function usePromptActions({ branchCurrentSession, createBackendSessionForSend, handleSkinCommand, + refreshSessions, requestGateway, selectedStoredSessionIdRef, startFreshSessionDraft, @@ -455,6 +458,50 @@ export function usePromptActions({ const renderSlashOutput = (text: string) => appendSessionTextMessage(sessionId, 'system', recordInput ? slashStatusText(command, text) : text) + // /title 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('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)) @@ -555,6 +602,7 @@ export function usePromptActions({ busyRef, createBackendSessionForSend, handleSkinCommand, + refreshSessions, requestGateway, startFreshSessionDraft, submitPromptText diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index d6164a70d..1ad9e3be9 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -25,6 +25,14 @@ export interface SlashExecResponse { warning?: string } +export interface SessionTitleResponse { + title?: string + // True when the session row isn't persisted yet and the title was queued + // to be applied on the first turn (see tui_gateway session.title handler). + pending?: boolean + session_key?: string +} + export interface ExecCommandDispatchResponse { type: 'exec' | 'plugin' output?: string