mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-08 06:32:44 +08:00
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:
@@ -133,7 +133,23 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
};
|
||||
}
|
||||
|
||||
let sessionName = nameMap.get(parsed.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, instead of the
|
||||
// generic "Untitled Codex Session" placeholder. Sessions discovered purely
|
||||
// by indexing (session_id === provider_session_id) keep the existing
|
||||
// thread_name/last-agent-message setup below.
|
||||
const isAppCreated =
|
||||
existingSession != null &&
|
||||
existingSession.provider_session_id != null &&
|
||||
existingSession.session_id !== existingSession.provider_session_id;
|
||||
|
||||
let sessionName = isAppCreated
|
||||
? await this.extractFirstUserMessageFromStart(filePath)
|
||||
: undefined;
|
||||
if (!sessionName) {
|
||||
sessionName = nameMap.get(parsed.sessionId);
|
||||
}
|
||||
if (!sessionName) {
|
||||
sessionName = await this.extractLastAgentMessageFromEnd(filePath);
|
||||
}
|
||||
@@ -144,6 +160,49 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first user message text in a Codex transcript, used to title
|
||||
* app-created sessions from the prompt the user sent from cloudcli.
|
||||
*
|
||||
* Reads the `event_msg`/`user_message` payload rather than the raw
|
||||
* `response_item` user turn so injected `<environment_context>` boilerplate is
|
||||
* never mistaken for the user's prompt.
|
||||
*/
|
||||
private async extractFirstUserMessageFromStart(filePath: string): Promise<string | undefined> {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = parsed as Record<string, unknown>;
|
||||
const eventType = typeof data.type === 'string' ? data.type : undefined;
|
||||
const payload = data.payload as Record<string, unknown> | undefined;
|
||||
const payloadType = typeof payload?.type === 'string' ? payload.type : undefined;
|
||||
const message = typeof payload?.message === 'string' ? payload.message : undefined;
|
||||
|
||||
if (eventType === 'event_msg' && payloadType === 'user_message' && message?.trim()) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore missing/unreadable files so sync can continue.
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async extractLastAgentMessageFromEnd(filePath: string): Promise<string | undefined> {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user