From 90e0ca8ab42a5b8624264d295c6b929c1e24bf45 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 16:56:07 +0100 Subject: [PATCH] stop showing opencode placeholder titles as if they were names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode titles a session from the conversation, but asynchronously — a finished turn of ours ended up called "Single color in oc-red2.png", better than anything we would generate. Until then the session is literally named "New session - 2026-08-10T15:44:17.178Z". That window is exactly when a session is most visible: /chat/live shows turns that are RUNNING, so the placeholder is what the panel catches, and a live row was being labelled with a timestamp string. Recognise it and treat it as untitled, so the good name arrives on its own. Passing --title on the run was the other option and is worse: it fixes the transient case by permanently replacing opencode own title with a truncated prompt, degrading it where it lasts longest. A pattern match rather than startsWith, because a genuine title is allowed to begin with those words. Two defects the tests caught while writing them: a whitespace-only title was not treated as unnamed, and the mapping let undefined through where a string was required. Co-Authored-By: Claude Opus 5 --- src/servers/api/chat/chat.ts | 8 +++--- .../api/chat/opencode-sessions.test.ts | 27 +++++++++++++++++++ src/servers/api/chat/opencode-sessions.ts | 19 ++++++++++++- 3 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 src/servers/api/chat/opencode-sessions.test.ts diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index efffece4..545b00da 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -150,9 +150,11 @@ chatRouter.get('/live', async (ctx) => { isGenerating: true, pendingTasks: 0, harness: 'opencode' as const, - // Still null for the first seconds of a turn: the id is only known once the subprocess prints it. - // That window is real and short, and showing nothing beats showing a key the user has never seen. - title: meta?.title ?? null, + // 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, cwd: meta?.cwd || null, }; }); diff --git a/src/servers/api/chat/opencode-sessions.test.ts b/src/servers/api/chat/opencode-sessions.test.ts new file mode 100644 index 00000000..e41e531b --- /dev/null +++ b/src/servers/api/chat/opencode-sessions.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'bun:test'; +import { isPlaceholderOpenCodeTitle } from './opencode-sessions'; + +// OpenCode names a session `New session - ` until it has titled it from the conversation, which it +// does asynchronously. The strings below are real ones observed on 1.18.16 — both the placeholder and +// the good title it was replaced by. + +describe('isPlaceholderOpenCodeTitle', () => { + it('recognises the placeholder OpenCode uses before it has titled a session', () => { + expect(isPlaceholderOpenCodeTitle('New session - 2026-08-10T15:44:17.178Z')).toBe(true); + expect(isPlaceholderOpenCodeTitle('New session - 2026-08-10T12:41:02.757Z')).toBe(true); + }); + + it('treats an absent or empty title as unnamed too', () => { + expect(isPlaceholderOpenCodeTitle(undefined)).toBe(true); + expect(isPlaceholderOpenCodeTitle('')).toBe(true); + expect(isPlaceholderOpenCodeTitle(' ')).toBe(true); + }); + + it('keeps a real title, including one that merely mentions a session', () => { + // The title OpenCode actually gave a turn of ours, and the reason this is a pattern match rather + // than a `startsWith('New session')`: a genuine title is allowed to begin with those words. + expect(isPlaceholderOpenCodeTitle('Single color in oc-red2.png')).toBe(false); + expect(isPlaceholderOpenCodeTitle('New session handling in the runner')).toBe(false); + expect(isPlaceholderOpenCodeTitle('New session - what should it be called?')).toBe(false); + }); +}); diff --git a/src/servers/api/chat/opencode-sessions.ts b/src/servers/api/chat/opencode-sessions.ts index 277c19dc..b265974d 100644 --- a/src/servers/api/chat/opencode-sessions.ts +++ b/src/servers/api/chat/opencode-sessions.ts @@ -24,6 +24,23 @@ import { logger } from './logger'; * the `!cwd` escape never fired either and there was no configuration in which an OpenCode session * appeared in /chat. Verified against the live server: 7 sessions present, 0 returned. */ +/** + * OpenCode's own placeholder name for a session it has not titled yet. + * + * It titles a session from the conversation, but ASYNCHRONOUSLY and after there is something to read — + * a finished turn ends up called "Single color in oc-red2.png", which is better than anything we would + * generate. Until then the session is literally named `New session - 2026-08-10T15:44:17.178Z`. + * + * That window is exactly when a session is most visible: `/chat/live` shows turns that are RUNNING, so + * the placeholder is what the panel catches. Treating it as "not yet titled" lets the good name arrive + * on its own. Forcing `--title` on the run instead would fix the transient case by permanently + * replacing opencode's title with a truncated prompt — worse where it lasts longest. + */ +export const isPlaceholderOpenCodeTitle = (title: string | undefined): boolean => { + const trimmed = title?.trim(); + return !trimmed || /^New session - \d{4}-\d{2}-\d{2}T[\d:.]+Z?$/.test(trimmed); +}; + export async function listOpenCodeSessions(cwd?: string): Promise { try { const { baseUrl } = await ensureServer(); @@ -35,7 +52,7 @@ export async function listOpenCodeSessions(cwd?: string): Promise