diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 545b00da..afdac3d7 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -21,7 +21,7 @@ import { renameOpenCodeSession, isOpenCodeSessionId, } from './opencode-sessions'; -import { getOpenCodeSession } from './opencode/state'; +import { getOpenCodePrompt, getOpenCodeSession } from './opencode/state'; import { listChatModels } from './list-models'; import { logger } from './logger'; import { readSttConfig } from '../server-settings/stt'; @@ -150,11 +150,12 @@ chatRouter.get('/live', async (ctx) => { isGenerating: true, pendingTasks: 0, harness: 'opencode' as const, - // Null for the first seconds of a turn (the id is only known once the subprocess prints it), and - // null again while OpenCode still calls the session `New session - ` — `listOpenCodeSessions` - // normalises that placeholder to `(untitled)`, and a live row is better blank than named after a - // timestamp. The real title arrives on a later poll, because OpenCode writes it asynchronously. - title: meta?.title && meta.title !== '(untitled)' ? meta.title : null, + // OpenCode's own title wins as soon as it exists — it is derived from the conversation and is + // better than anything we would compose. Until then (and it titles asynchronously, so "until + // then" covers the whole time a turn is RUNNING, which is exactly what this panel shows) fall + // back to the prompt that started the session. Same shape as the Claude side, which has never + // shown a live row without a name. + title: meta?.title && meta.title !== '(untitled)' ? meta.title : (getOpenCodePrompt(sessionKey) ?? null), cwd: meta?.cwd || null, }; }); diff --git a/src/servers/api/chat/opencode/state.ts b/src/servers/api/chat/opencode/state.ts index e7edf05c..8e852c0b 100644 --- a/src/servers/api/chat/opencode/state.ts +++ b/src/servers/api/chat/opencode/state.ts @@ -12,4 +12,25 @@ export const setOpenCodeSession = (sessionKey: string, opencodeSessionId: string export const clearOpenCodeSession = (sessionKey: string): void => { sessionKeyToOpenCode.delete(sessionKey); + sessionKeyToPrompt.delete(sessionKey); }; + +// What the user asked, kept only to name a session OpenCode has not named yet. +// +// OpenCode titles a session from the conversation, and does it well — but asynchronously, so a RUNNING +// turn is called `New session - `, which is precisely when `/chat/live` displays it. This is the +// stand-in for that window: shown while the real title is still the placeholder, and dropped the moment +// OpenCode publishes its own, which is always the better one. +// +// Set on the FIRST turn of a session only, so it stays the name of the conversation rather than +// following whatever was asked most recently. +const sessionKeyToPrompt = new Map(); + +export const rememberOpenCodePrompt = (sessionKey: string, prompt: string): void => { + if (sessionKeyToPrompt.has(sessionKey)) return; + const oneLine = prompt.replace(/\s+/g, ' ').trim(); + if (!oneLine) return; + sessionKeyToPrompt.set(sessionKey, oneLine.length > 60 ? `${oneLine.slice(0, 59)}…` : oneLine); +}; + +export const getOpenCodePrompt = (sessionKey: string): string | undefined => sessionKeyToPrompt.get(sessionKey); diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 7bac312d..ceeccce8 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -10,6 +10,7 @@ import type { UserSession, } from './types'; import { sessionManager } from './session-manager'; +import { rememberOpenCodePrompt } from './opencode/state'; import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code'; import { sendOpenCodeStreaming } from '@@/channels/send-opencode'; import { ensureGeneralChatSessionsCwd } from './claude-sessions'; @@ -407,6 +408,9 @@ async function handleOpenCodeChat( const cwd = await resolveChatCwd(msg, email, userId); + // Names this session in the Live panel until OpenCode gets round to titling it. First turn only. + rememberOpenCodePrompt(sessionId, msg.displayText || msg.prompt); + const groupSlug = msg.groupSlug || null; const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId);