fix(sessions): title app-created sessions from the first user message

App-created sessions (started by sending a message from cloudcli) were
titled with placeholder names — "Untitled Codex Session" from the disk
indexer and, briefly, "New session" from the empty canonical upsert —
before a later sync finally settled on the right text, causing the
sidebar title to flicker.

- Codex/OpenCode synchronizers now title app-created sessions (distinct
  app id mapped to a provider id) from the first user message, while
  sessions found purely by indexing keep their existing setup. Claude
  keeps its AI-generated titles; Cursor already used the first message.
- Decode OpenCode's JSON-string-literal prompts so titles no longer
  surface wrapped in quotes; hoist unwrapJsonStringLiteral into
  shared/utils since it's now used by both the reader and synchronizer.
- Guard the sidebar upsert merge so an empty summary can never blank out
  a title that is already set.
- Add codex/opencode synchronizer tests for the app-created vs indexed
  naming paths.
This commit is contained in:
Haileyesus
2026-07-07 15:07:50 +03:00
parent 6336d94fd6
commit 472c7a8055
7 changed files with 331 additions and 24 deletions

View File

@@ -11,6 +11,7 @@ import {
normalizeSessionName,
readJsonRecord,
readOptionalString,
unwrapJsonStringLiteral,
} from '@/shared/utils.js';
type OpenCodeSessionRow = {
@@ -128,9 +129,26 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
const existingSession = sessionsDb.getSessionByProviderSessionId(sessionId)
?? sessionsDb.getSessionById(sessionId);
const existingName = existingSession?.custom_name;
const nextName = existingName && existingName !== fallbackTitle
? existingName
: readOptionalString(row.title) ?? this.readFirstUserText(db, sessionId);
// Sessions started by sending a message from cloudcli carry a distinct
// app-allocated session_id mapped to the provider id. For these we title the
// conversation from the first user message the user typed, matching how the
// app titles a brand-new conversation. Sessions discovered purely by
// indexing (session_id === provider_session_id) keep OpenCode's own stored
// title.
const isAppCreated =
existingSession != null &&
existingSession.provider_session_id != null &&
existingSession.session_id !== existingSession.provider_session_id;
let nextName: string | undefined;
if (existingName && existingName !== fallbackTitle) {
nextName = existingName;
} else if (isAppCreated) {
nextName = this.readFirstUserText(db, sessionId) ?? readOptionalString(row.title);
} else {
nextName = readOptionalString(row.title) ?? this.readFirstUserText(db, sessionId);
}
// OpenCode stores every session in one shared sqlite database, so jsonl_path
// must stay null to avoid deleting opencode.db when one app session is removed.
@@ -163,7 +181,10 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
`).get(sessionId) as { data: string | null } | undefined;
const data = readJsonRecord(row?.data);
return readOptionalString(data?.text);
const text = readOptionalString(data?.text);
// OpenCode persists the first prompt as a JSON string literal (e.g.
// `"hello"`), so decode it to avoid titling the session with quotes.
return text === undefined ? undefined : unwrapJsonStringLiteral(text);
} catch {
return undefined;
}

View File

@@ -14,6 +14,7 @@ import {
readJsonRecord,
readOptionalString,
sliceTailPage,
unwrapJsonStringLiteral,
} from '@/shared/utils.js';
const PROVIDER = 'opencode';
@@ -60,25 +61,6 @@ const formatToolContent = (value: unknown): string => {
}
};
/**
* OpenCode can persist the first prompt as a JSON string literal inside a text
* part, for example `"hello"` instead of `hello`. Decode only complete JSON
* string literals so normal assistant/user prose remains untouched.
*/
const unwrapJsonStringLiteral = (value: string): string => {
const trimmed = value.trim();
if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) {
return value;
}
try {
const parsed = JSON.parse(trimmed);
return typeof parsed === 'string' ? parsed : value;
} catch {
return value;
}
};
const extractText = (value: unknown): string => {
if (typeof value === 'string') {
return unwrapJsonStringLiteral(value);