Compare commits

...

3 Commits

Author SHA1 Message Date
Haileyesus
c1d370d48b fix(chat): make message queuing reliable across sessions and turn boundaries
Queued messages had four related defects:

- A queued message flashed and then vanished at flush time: concurrent
  transcript refreshes (the `complete` handler racing the watcher-triggered
  update) could resolve out of order, letting a stale response overwrite
  newer server messages after the optimistic row was pruned. Session slots
  now carry a monotonic fetch ticket and discard stale fetch/refresh/
  fetchMore responses.

- Switching sessions flushed the previous session's queued draft into the
  newly viewed one (sending it with the wrong provider's settings, e.g. a
  Claude model into a Codex session). The composer flush is now scoped to
  its session, and a draft restored into an idle session sends after a
  short grace period so a live-run ack can cancel it.

- The thinking banner never appeared for a queued turn: the chat handler's
  session-keyed completeRun safety net could fire after a queued message
  had already started the session's next run, emitting a spurious
  `complete` that killed it. The safety net is now scoped to its own run
  via completeRunIfCurrent (with a regression test).

- Queued messages only sent while their session was being viewed. Drafts
  now persist their send options (model, effort, permissions) at queue
  time, and a new app-level useQueuedMessageAutoSend hook dispatches a
  non-viewed session's queued message as soon as its run completes, using
  the storage key as the claim ticket to prevent double sends.
2026-07-07 20:01:29 +03:00
Haileyesus
472c7a8055 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.
2026-07-07 15:07:50 +03:00
Haileyesus
6336d94fd6 fix: show agent subtask 2026-07-07 15:06:06 +03:00
16 changed files with 763 additions and 110 deletions

View File

@@ -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');

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);

View File

@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
import { CodexSessionSynchronizer } from '@/modules/providers/list/codex/codex-session-synchronizer.provider.js';
const patchHomeDir = (nextHomeDir: string) => {
const original = os.homedir;
(os as any).homedir = () => nextHomeDir;
return () => {
(os as any).homedir = original;
};
};
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
const previousDatabasePath = process.env.DATABASE_PATH;
const tempDirectory = await mkdtemp(path.join(os.tmpdir(), 'codex-provider-db-'));
const databasePath = path.join(tempDirectory, 'auth.db');
closeConnection();
process.env.DATABASE_PATH = databasePath;
await initializeDatabase();
try {
await runTest();
} finally {
closeConnection();
if (previousDatabasePath === undefined) {
delete process.env.DATABASE_PATH;
} else {
process.env.DATABASE_PATH = previousDatabasePath;
}
await rm(tempDirectory, { recursive: true, force: true });
}
}
/**
* Writes one Codex rollout transcript. `firstUserMessage` mirrors the
* `event_msg`/`user_message` payload the runtime records for the prompt the
* user typed; omitting it produces a transcript with no user turn.
*/
const writeCodexTranscript = async (
homeDir: string,
codexSessionId: string,
workspacePath: string,
firstUserMessage?: string,
): Promise<string> => {
const sessionsDir = path.join(homeDir, '.codex', 'sessions', '2026', '07', '07');
await mkdir(sessionsDir, { recursive: true });
const lines: string[] = [
JSON.stringify({ type: 'session_meta', payload: { id: codexSessionId, cwd: workspacePath } }),
];
if (firstUserMessage !== undefined) {
lines.push(JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: firstUserMessage } }));
}
const filePath = path.join(sessionsDir, `rollout-${codexSessionId}.jsonl`);
await writeFile(filePath, `${lines.join('\n')}\n`, 'utf8');
return filePath;
};
test('Codex synchronizer titles app-created sessions from the first user message', { concurrency: false }, async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-session-sync-app-'));
const workspacePath = path.join(tempRoot, 'workspace');
await mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await writeCodexTranscript(tempRoot, 'codex-app-1', workspacePath, 'Fix the login redirect bug');
await withIsolatedDatabase(async () => {
// The app allocates its own id and later maps the provider id onto it,
// exactly as a message sent from cloudcli does.
sessionsDb.createAppSession('app-1', 'codex', workspacePath);
sessionsDb.assignProviderSessionId('app-1', 'codex-app-1');
const synchronizer = new CodexSessionSynchronizer();
await synchronizer.synchronize();
assert.equal(sessionsDb.getSessionById('app-1')?.custom_name, 'Fix the login redirect bug');
});
} finally {
restoreHomeDir();
await rm(tempRoot, { recursive: true, force: true });
}
});
test('Codex synchronizer leaves indexed sessions untitled when no name is available', { concurrency: false }, async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-session-sync-indexed-'));
const workspacePath = path.join(tempRoot, 'workspace');
await mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
// A CLI-created session has no app row; its first user message must NOT be
// used as the title, preserving the existing indexing behavior.
await writeCodexTranscript(tempRoot, 'codex-indexed-1', workspacePath, 'This prompt should be ignored');
await withIsolatedDatabase(async () => {
const synchronizer = new CodexSessionSynchronizer();
await synchronizer.synchronize();
assert.equal(sessionsDb.getSessionById('codex-indexed-1')?.custom_name, 'Untitled Codex Session');
});
} finally {
restoreHomeDir();
await rm(tempRoot, { recursive: true, force: true });
}
});

View File

@@ -417,3 +417,106 @@ test('OpenCode sessions provider reads sqlite history and token usage', { concur
await rm(tempRoot, { recursive: true, force: true });
}
});
/**
* Seeds a single OpenCode session with a controllable stored title and first
* user message. Uses a minimal schema (only the columns the synchronizer reads)
* with a plain-text user part so the derived name is unambiguous.
*/
const seedOpenCodeSession = async (
homeDir: string,
workspacePath: string,
options: { sessionId: string; title: string | null; firstUserText: string },
): Promise<void> => {
const dataDir = path.join(homeDir, '.local', 'share', 'opencode');
await mkdir(dataDir, { recursive: true });
const db = new Database(path.join(dataDir, 'opencode.db'));
try {
db.exec(`
CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT);
CREATE TABLE session (
id TEXT PRIMARY KEY,
project_id TEXT,
directory TEXT,
title TEXT,
time_created INTEGER,
time_updated INTEGER,
time_archived INTEGER
);
CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT);
CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, time_created INTEGER, data TEXT);
`);
db.prepare('INSERT INTO project (id, worktree) VALUES (?, ?)').run('project-1', workspacePath);
db.prepare(`
INSERT INTO session (id, project_id, directory, title, time_created, time_updated, time_archived)
VALUES (?, ?, ?, ?, ?, ?, NULL)
`).run(options.sessionId, 'project-1', workspacePath, options.title, 1_700_000_000_000, 1_700_000_001_000);
db.prepare('INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)')
.run('message-user', options.sessionId, 1_700_000_001_000, JSON.stringify({ role: 'user' }));
db.prepare('INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)')
.run(
'part-user',
'message-user',
options.sessionId,
1_700_000_001_000,
// OpenCode persists the prompt as a JSON string literal inside the text
// field, so double-encode it here to exercise the unwrap on read.
JSON.stringify({ type: 'text', text: JSON.stringify(options.firstUserText) }),
);
} finally {
db.close();
}
};
test('OpenCode synchronizer titles app-created sessions from the first user message', { concurrency: false }, async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-app-'));
const workspacePath = path.join(tempRoot, 'workspace');
await mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
// Stored title differs from the first message so we can prove the first
// message wins for sessions started from cloudcli.
await seedOpenCodeSession(tempRoot, workspacePath, {
sessionId: 'oc-app-1',
title: 'OpenCode generated title',
firstUserText: 'Fix the checkout crash',
});
await withIsolatedDatabase(async () => {
sessionsDb.createAppSession('app-1', 'opencode', workspacePath);
sessionsDb.assignProviderSessionId('app-1', 'oc-app-1');
await new OpenCodeSessionSynchronizer().synchronize();
assert.equal(sessionsDb.getSessionById('app-1')?.custom_name, 'Fix the checkout crash');
});
} finally {
restoreHomeDir();
await rm(tempRoot, { recursive: true, force: true });
}
});
test('OpenCode synchronizer keeps the stored title for indexed sessions', { concurrency: false }, async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-indexed-'));
const workspacePath = path.join(tempRoot, 'workspace');
await mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await seedOpenCodeSession(tempRoot, workspacePath, {
sessionId: 'oc-indexed-1',
title: 'OpenCode generated title',
firstUserText: 'This prompt should be ignored',
});
await withIsolatedDatabase(async () => {
await new OpenCodeSessionSynchronizer().synchronize();
assert.equal(sessionsDb.getSessionById('oc-indexed-1')?.custom_name, 'OpenCode generated title');
});
} finally {
restoreHomeDir();
await rm(tempRoot, { recursive: true, force: true });
}
});

View File

@@ -318,6 +318,22 @@ export const chatRunRegistry = {
run.writer.sendComplete(opts);
},
/**
* Safety-net variant of `completeRun` scoped to one specific run: a no-op
* unless `run` is still the session's current, running run. A runtime
* promise can resolve after its own `complete` already streamed AND a new
* run has replaced it in the registry (a queued message sends within
* milliseconds of the previous turn ending) — the session-keyed
* `completeRun` would terminate that newer run.
*/
completeRunIfCurrent(run: ChatRun, opts: { exitCode: number; aborted?: boolean }): void {
if (runs.get(run.appSessionId) !== run || run.status !== 'running') {
return;
}
run.writer.sendComplete(opts);
},
/**
* Test-only escape hatch: clears every tracked run.
*/

View File

@@ -212,8 +212,10 @@ async function handleChatSend(
} finally {
// Safety net: a runtime that crashed (or resolved) without emitting its
// terminal `complete` would otherwise leave the session stuck in
// "processing" forever on every connected client.
chatRunRegistry.completeRun(sessionId, { exitCode: 1 });
// "processing" forever on every connected client. Scoped to THIS run —
// a queued message can start the session's next run before this promise
// settles, and the session-keyed completeRun would kill that new run.
chatRunRegistry.completeRunIfCurrent(run, { exitCode: 1 });
}
}

View File

@@ -129,6 +129,44 @@ test('complete marks the run finished and duplicate completes are dropped', asyn
});
});
test('a finished run\'s safety net cannot complete the session\'s next run', async () => {
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-run-9', 'codex', '/workspace/demo');
const connection = new FakeConnection();
const firstRun = chatRunRegistry.startRun({
appSessionId: 'app-run-9',
provider: 'codex',
providerSessionId: null,
connection,
userId: null,
});
assert.ok(firstRun);
firstRun.writer.send({ kind: 'complete', provider: 'codex', sessionId: 'native-9', exitCode: 0 });
// A queued message starts the next run before the first run's runtime
// promise settles (the chat handler's `finally` hasn't executed yet).
const secondRun = chatRunRegistry.startRun({
appSessionId: 'app-run-9',
provider: 'codex',
providerSessionId: null,
connection,
userId: null,
});
assert.ok(secondRun);
// First run's safety net fires late: it must not touch the new run.
chatRunRegistry.completeRunIfCurrent(firstRun, { exitCode: 1 });
assert.equal(chatRunRegistry.isProcessing('app-run-9'), true);
assert.equal(connection.frames.filter((frame) => frame.kind === 'complete').length, 1);
// The second run's own safety net still works while it is current.
chatRunRegistry.completeRunIfCurrent(secondRun, { exitCode: 1 });
assert.equal(chatRunRegistry.isProcessing('app-run-9'), false);
assert.equal(connection.frames.filter((frame) => frame.kind === 'complete').length, 2);
});
});
test('listRunningRuns returns only currently running app sessions', async () => {
await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-run-7', 'claude', '/workspace/demo');

View File

@@ -1066,6 +1066,30 @@ export function getOpenCodeDatabasePath(): string {
return path.join(os.homedir(), '.local', 'share', 'opencode', 'opencode.db');
}
/**
* Decodes an OpenCode text payload that was persisted as a JSON string literal.
*
* OpenCode can store the first user prompt (and other text parts) as `"hello"`
* instead of `hello`. Used by both the OpenCode session reader (transcript
* history) and the OpenCode synchronizer (session titling) so a session name or
* message body never surfaces with surrounding quote characters. Only fully
* quoted, valid JSON string literals are unwrapped; ordinary prose that merely
* happens to start/end with a quote is returned untouched.
*/
export function 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;
}
}
// ---------------------------
//----------------- SAFE DIRECTORY NAME UTILITIES ------------
/**

View File

@@ -10,6 +10,7 @@ import { PaletteOpsProvider, usePaletteOpsRegister } from '../../contexts/Palett
import { useDeviceSettings } from '../../hooks/useDeviceSettings';
import { useSessionProtection } from '../../hooks/useSessionProtection';
import { useProjectsState } from '../../hooks/useProjectsState';
import { useQueuedMessageAutoSend } from '../../hooks/useQueuedMessageAutoSend';
import { api } from '../../utils/api';
type RunningSessionApiItem = {
@@ -84,6 +85,17 @@ function AppContentInner() {
activeSessions: processingSessions,
});
// Queued messages for sessions that finish while another session (or none)
// is being viewed are sent from here; the viewed session's composer handles
// its own queue.
useQueuedMessageAutoSend({
processingSessions,
activeSessionId: selectedSession?.id ?? sessionId ?? null,
ws,
sendMessage,
markSessionProcessing,
});
const refreshRunningSessions = useCallback(async () => {
try {
const response = await api.runningSessions();

View File

@@ -14,7 +14,13 @@ import { useDropzone } from 'react-dropzone';
import { authenticatedFetch } from '../../../utils/api';
import type { MarkSessionProcessing } from '../../../hooks/useSessionProtection';
import { grantClaudeToolPermission } from '../utils/chatPermissions';
import { safeLocalStorage } from '../utils/chatStorage';
import {
clearQueuedMessage,
readQueuedMessage,
safeLocalStorage,
writeQueuedMessage,
type QueuedSendOptions,
} from '../utils/chatStorage';
import type {
ChatMessage,
PendingPermissionRequest,
@@ -147,6 +153,18 @@ const createFakeSubmitEvent = () => {
export type QueuedDraft = {
content: string;
images: File[];
/**
* Send options snapshotted at queue time. Persisted with the draft so the
* app-level auto-send can dispatch the message with the right model and
* permission settings while another session is being viewed.
*/
options?: QueuedSendOptions;
};
const restoreQueuedDraft = (sessionKey: string): QueuedDraft | null => {
const saved = readQueuedMessage(sessionKey);
// Image attachments can't survive a reload; only text and options persist.
return saved ? { content: saved.content, images: [], options: saved.options } : null;
};
const getNotificationSessionSummary = (
@@ -227,9 +245,13 @@ export function useChatComposerState({
if (typeof window === 'undefined' || !sessionKey) {
return null;
}
const saved = safeLocalStorage.getItem(`queued_message_${sessionKey}`);
return saved ? { content: saved, images: [] } : null;
return restoreQueuedDraft(sessionKey);
});
// Which session the in-memory `queuedDraft` belongs to. On a session switch
// there is one commit where `sessionKey` already points at the new session
// while `queuedDraft` still holds the old session's draft; the persistence
// effect must not write across that gap.
const queuedDraftSessionRef = useRef<string | null>(sessionKey);
const handleBuiltInCommand = useCallback(
(result: CommandExecutionResult) => {
@@ -561,6 +583,66 @@ export function useChatComposerState({
noKeyboard: true,
});
// Snapshot of everything `chat.send` needs beyond the text itself. Built at
// send time for immediate sends and at queue time for queued ones, so a
// queued message keeps the provider settings it was composed under even if
// it is later dispatched outside this composer (app-level auto-send).
const buildSendOptions = useCallback((currentInput: string): QueuedSendOptions => {
const getToolsSettings = () => {
try {
const settingsKey =
provider === 'cursor'
? 'cursor-tools-settings'
: provider === 'codex'
? 'codex-settings'
: provider === 'opencode'
? 'opencode-settings'
: 'claude-settings';
const savedSettings = safeLocalStorage.getItem(settingsKey);
if (savedSettings) {
return JSON.parse(savedSettings);
}
} catch (error) {
console.error('Error loading tools settings:', error);
}
return {
allowedTools: [],
disallowedTools: [],
skipPermissions: false,
};
};
const toolsSettings = getToolsSettings();
const model =
provider === 'cursor'
? cursorModel
: provider === 'codex'
? codexModel
: provider === 'opencode'
? opencodeModel
: claudeModel;
return {
model,
effort: currentProviderEffort,
permissionMode: resolvePermissionModeForProvider(provider, permissionMode),
toolsSettings,
skipPermissions: toolsSettings?.skipPermissions || false,
sessionSummary: getNotificationSessionSummary(selectedSession, currentInput),
};
}, [
claudeModel,
codexModel,
currentProviderEffort,
cursorModel,
opencodeModel,
permissionMode,
provider,
resolvePermissionModeForProvider,
selectedSession,
]);
const handleSubmit = useCallback(
async (
event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>,
@@ -575,7 +657,12 @@ export function useChatComposerState({
// It's auto-flushed (re-running this same function) once the turn ends,
// so it still goes through slash-command interception, image upload, etc.
if (isLoading) {
setQueuedDraft({ content: currentInput, images: attachedImages });
queuedDraftSessionRef.current = sessionKey;
setQueuedDraft({
content: currentInput,
images: attachedImages,
options: buildSendOptions(currentInput),
});
setInput('');
inputValueRef.current = '';
setAttachedImages([]);
@@ -728,42 +815,6 @@ export function useChatComposerState({
setIsUserScrolledUp(false);
setTimeout(() => scrollToBottom(), 100);
const getToolsSettings = () => {
try {
const settingsKey =
provider === 'cursor'
? 'cursor-tools-settings'
: provider === 'codex'
? 'codex-settings'
: provider === 'opencode'
? 'opencode-settings'
: 'claude-settings';
const savedSettings = safeLocalStorage.getItem(settingsKey);
if (savedSettings) {
return JSON.parse(savedSettings);
}
} catch (error) {
console.error('Error loading tools settings:', error);
}
return {
allowedTools: [],
disallowedTools: [],
skipPermissions: false,
};
};
const toolsSettings = getToolsSettings();
const model =
provider === 'cursor'
? cursorModel
: provider === 'codex'
? codexModel
: provider === 'opencode'
? opencodeModel
: claudeModel;
const effort = currentProviderEffort;
// One message shape for every provider. The backend resolves the
// provider, project path, and provider-native resume id from the
// session row; `options` only carries composer-level preferences.
@@ -772,12 +823,7 @@ export function useChatComposerState({
sessionId: targetSessionId,
content: messageContent,
options: {
model,
effort,
permissionMode: resolvePermissionModeForProvider(provider, permissionMode),
toolsSettings,
skipPermissions: toolsSettings?.skipPermissions || false,
sessionSummary,
...buildSendOptions(messageContent),
images: uploadedImages,
},
});
@@ -799,23 +845,18 @@ export function useChatComposerState({
[
selectedSession,
attachedImages,
claudeModel,
codexModel,
currentProviderEffort,
buildSendOptions,
currentSessionId,
cursorModel,
executeCommand,
opencodeModel,
isLoading,
onSessionProcessing,
onSessionEstablished,
permissionMode,
provider,
resolvePermissionModeForProvider,
resetCommandMenuState,
scrollToBottom,
selectedProject,
sendMessage,
sessionKey,
addMessage,
setIsUserScrolledUp,
slashCommands,
@@ -829,21 +870,47 @@ export function useChatComposerState({
// Once the in-flight turn ends, replay the queued draft through the normal
// submit path (slash commands, image upload, etc. all still apply).
const wasLoadingRef = useRef(isLoading);
const flushSessionKeyRef = useRef(sessionKey);
useEffect(() => {
const wasLoading = wasLoadingRef.current;
wasLoadingRef.current = isLoading;
if (!wasLoading || isLoading || !queuedDraft) {
// A session switch changes which session `isLoading` describes, so this
// transition says nothing about the queued draft's own session. Never
// flush across it — the swap effect below replaces `queuedDraft` with the
// new session's saved draft right after this.
if (flushSessionKeyRef.current !== sessionKey) {
flushSessionKeyRef.current = sessionKey;
return;
}
setQueuedDraft(null);
setInput(queuedDraft.content);
inputValueRef.current = queuedDraft.content;
setAttachedImages(queuedDraft.images);
setTimeout(() => {
handleSubmitRef.current?.(createFakeSubmitEvent());
}, 0);
}, [isLoading, queuedDraft]);
if (isLoading || !queuedDraft) {
return;
}
// Turn just ended in this session: flush immediately. Otherwise this is a
// saved draft restored into an apparently idle session — hold it briefly
// so the `chat_subscribed` ack can flip `isLoading` if a run is actually
// still live (the cleanup below cancels the send in that case).
const delay = wasLoading ? 0 : 750;
const timer = setTimeout(() => {
// The saved key is the claim ticket shared with the app-level auto-send
// (which handles sessions that finish while not viewed). If it's gone,
// the message was already dispatched — don't send it twice.
if (sessionKey && !readQueuedMessage(sessionKey)) {
setQueuedDraft(null);
return;
}
setQueuedDraft(null);
setInput(queuedDraft.content);
inputValueRef.current = queuedDraft.content;
setAttachedImages(queuedDraft.images);
setTimeout(() => {
handleSubmitRef.current?.(createFakeSubmitEvent());
}, 0);
}, delay);
return () => clearTimeout(timer);
}, [isLoading, queuedDraft, sessionKey, setInput]);
const editQueuedDraft = useCallback(() => {
if (!queuedDraft) {
@@ -898,28 +965,33 @@ export function useChatComposerState({
}
}, [input, selectedProjectId]);
// Switching sessions swaps in that session's queued draft (image
// attachments can't survive a reload, so only the text is restored).
// Persist the queued draft under its session's key. Must be defined BEFORE
// the swap effect below: on a session switch there is one commit where
// `sessionKey` already points at the new session while `queuedDraft` (and
// the owner ref) still describe the old one — the ref mismatch makes this
// effect skip that commit instead of writing/clearing across sessions.
useEffect(() => {
if (!sessionKey || queuedDraftSessionRef.current !== sessionKey) {
return;
}
if (queuedDraft?.content) {
writeQueuedMessage(sessionKey, { content: queuedDraft.content, options: queuedDraft.options });
} else {
clearQueuedMessage(sessionKey);
}
}, [queuedDraft, sessionKey]);
// Switching sessions swaps in that session's queued draft (image
// attachments can't survive a reload, so only text and options restore).
useEffect(() => {
queuedDraftSessionRef.current = sessionKey;
if (!sessionKey) {
setQueuedDraft(null);
return;
}
const saved = safeLocalStorage.getItem(`queued_message_${sessionKey}`);
setQueuedDraft(saved ? { content: saved, images: [] } : null);
setQueuedDraft(restoreQueuedDraft(sessionKey));
}, [sessionKey]);
useEffect(() => {
if (!sessionKey) {
return;
}
if (queuedDraft?.content) {
safeLocalStorage.setItem(`queued_message_${sessionKey}`, queuedDraft.content);
} else {
safeLocalStorage.removeItem(`queued_message_${sessionKey}`);
}
}, [queuedDraft, sessionKey]);
useEffect(() => {
if (!textareaRef.current) {
return;

View File

@@ -13,6 +13,48 @@ function formatToolResultContent(content: unknown): string {
return toolUseErrorMatch ? toolUseErrorMatch[1] : text;
}
type ParsedTaskNotification = {
status: string;
summary: string;
result: string;
};
/**
* Parses a background-agent `<task-notification>` block.
*
* The harness injects these as user-role messages when a background task stops.
* Newer notifications carry extra fields (`<tool-use-id>`, `<note>`, `<usage>`,
* and a `<result>` markdown payload) that the previous single-shot regex could
* not match, so the whole raw XML block leaked through as plain user text.
* Fields are extracted independently so the block renders as an assistant
* notification plus, when present, the agent's markdown result.
*/
function parseTaskNotification(content: string): ParsedTaskNotification | null {
if (!content.trimStart().startsWith('<task-notification>')) {
return null;
}
const statusMatch = /<status>([\s\S]*?)<\/status>/.exec(content);
const summaryMatch = /<summary>([\s\S]*?)<\/summary>/.exec(content);
let result = '';
const resultOpen = content.indexOf('<result>');
if (resultOpen !== -1) {
const afterOpen = content.slice(resultOpen + '<result>'.length);
const closeIndex = afterOpen.indexOf('</result>');
result =
closeIndex === -1
? afterOpen.replace(/<\/task-notification>\s*$/, '').trim()
: afterOpen.slice(0, closeIndex).trim();
}
return {
status: statusMatch?.[1]?.trim() || 'completed',
summary: summaryMatch?.[1]?.trim() || 'Background task finished',
result,
};
}
/**
* Convert NormalizedMessage[] from the session store into ChatMessage[]
* that the existing UI components expect.
@@ -56,17 +98,26 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
if (msg.role === 'user') {
// Parse task notifications
const taskNotifRegex = /<task-notification>\s*<task-id>[^<]*<\/task-id>\s*<output-file>[^<]*<\/output-file>\s*<status>([^<]*)<\/status>\s*<summary>([^<]*)<\/summary>\s*<\/task-notification>/g;
const taskNotifMatch = taskNotifRegex.exec(content);
if (taskNotifMatch) {
const taskNotif = parseTaskNotification(content);
if (taskNotif) {
converted.push({
type: 'assistant',
content: taskNotifMatch[2]?.trim() || 'Background task finished',
content: taskNotif.summary,
timestamp: msg.timestamp,
isTaskNotification: true,
taskStatus: taskNotifMatch[1]?.trim() || 'completed',
taskStatus: taskNotif.status,
...sharedMetadata,
});
// Render the agent's result as a normal assistant message so its
// markdown displays correctly instead of leaking raw XML.
if (taskNotif.result) {
converted.push({
type: 'assistant',
content: formatUsageLimitText(unescapeWithMathProtection(decodeHtmlEntities(taskNotif.result))),
timestamp: msg.timestamp,
...sharedMetadata,
});
}
} else {
converted.push({
type: 'user',

View File

@@ -43,6 +43,52 @@ export const safeLocalStorage = {
},
};
/**
* Composer options captured when a message is queued, so the message can be
* sent later with the exact settings (model, permission mode, tools) the
* session's composer had at queue time — even from outside the composer,
* e.g. the app-level auto-send that fires while another session is viewed.
*/
export type QueuedSendOptions = Record<string, unknown>;
export type StoredQueuedMessage = {
content: string;
options?: QueuedSendOptions;
};
export const queuedMessageKey = (sessionId: string) => `queued_message_${sessionId}`;
/**
* Reads a session's queued message. Understands both the JSON
* `{ content, options }` format and the legacy raw-text format.
*/
export function readQueuedMessage(sessionId: string): StoredQueuedMessage | null {
const raw = safeLocalStorage.getItem(queuedMessageKey(sessionId));
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === 'object' && typeof (parsed as StoredQueuedMessage).content === 'string') {
const { content, options } = parsed as StoredQueuedMessage;
return content.trim() ? { content, options } : null;
}
} catch {
// Legacy format: the raw draft text itself.
}
return raw.trim() ? { content: raw } : null;
}
export function writeQueuedMessage(sessionId: string, message: StoredQueuedMessage): void {
safeLocalStorage.setItem(queuedMessageKey(sessionId), JSON.stringify(message));
}
export function clearQueuedMessage(sessionId: string): void {
safeLocalStorage.removeItem(queuedMessageKey(sessionId));
}
export function getClaudeSettings(): ClaudeSettings {
const raw = safeLocalStorage.getItem(CLAUDE_SETTINGS_KEY);
if (!raw) {

View File

@@ -255,6 +255,13 @@ const upsertSessionIntoProject = (project: Project, event: SessionUpsertedEvent)
for (const [index, session] of sessions.entries()) {
if (index === existingIndex) {
const updated = { ...session, ...normalizedSession };
// Never let a later upsert that carries an empty summary blank out a
// title we already have. Fresh sessions momentarily broadcast an empty
// custom_name before the disk indexer fills it in, which would
// otherwise flash the row back to the "New session" placeholder.
if (!normalizedSession.summary?.trim() && session.summary?.trim()) {
updated.summary = session.summary;
}
if (serialize(session) !== serialize(updated)) {
changed = true;
}

View File

@@ -0,0 +1,70 @@
import { useEffect, useRef } from 'react';
import { clearQueuedMessage, readQueuedMessage } from '../components/chat/utils/chatStorage';
import type { MarkSessionProcessing, SessionActivityMap } from './useSessionProtection';
interface UseQueuedMessageAutoSendArgs {
processingSessions: SessionActivityMap;
/**
* The session currently open in the chat view. Its queued draft is owned by
* the composer (which also handles image attachments and slash commands),
* so this hook never touches it.
*/
activeSessionId: string | null;
ws: WebSocket | null;
sendMessage: (message: unknown) => void;
markSessionProcessing: MarkSessionProcessing;
}
/**
* Dispatches queued messages for sessions the user is NOT currently viewing.
*
* The composer persists each queued draft (text + send options snapshotted at
* queue time) under `queued_message_<sessionId>`. When a session's run leaves
* the processing map — its previous response completed — this hook sends that
* session's queued message immediately instead of waiting for the user to
* open the session again. Removing the storage key before sending is the
* claim that keeps the composer's own flush from double-sending.
*/
export function useQueuedMessageAutoSend({
processingSessions,
activeSessionId,
ws,
sendMessage,
markSessionProcessing,
}: UseQueuedMessageAutoSendArgs) {
const prevProcessingRef = useRef<ReadonlySet<string>>(new Set());
useEffect(() => {
const prev = prevProcessingRef.current;
const current = new Set(processingSessions.keys());
prevProcessingRef.current = current;
for (const sessionId of prev) {
if (current.has(sessionId) || sessionId === activeSessionId) {
continue;
}
const queued = readQueuedMessage(sessionId);
if (!queued) {
continue;
}
// A closed socket would drop the send silently; keep the draft so the
// composer (or a later completion) can retry once we're connected.
if (!ws || ws.readyState !== WebSocket.OPEN) {
continue;
}
clearQueuedMessage(sessionId);
sendMessage({
type: 'chat.send',
sessionId,
content: queued.content,
options: { ...(queued.options ?? {}), images: [] },
});
markSessionProcessing(sessionId, { statusText: null, canInterrupt: true });
}
}, [processingSessions, activeSessionId, ws, sendMessage, markSessionProcessing]);
}

View File

@@ -97,6 +97,16 @@ export interface SessionSlot {
/** @internal Cache-invalidation refs for computeMerged */
_lastServerRef: NormalizedMessage[];
_lastRealtimeRef: NormalizedMessage[];
/**
* @internal Monotonic ticket per server fetch (fetch/refresh/fetchMore) and
* the ticket of the last response applied. Concurrent fetches for the same
* session can resolve out of order — e.g. the `complete` refresh racing the
* watcher-triggered refresh right as a queued message is flushed — and a
* stale response applied last would wind `serverMessages` back to a
* transcript that no longer matches what the user already saw.
*/
_fetchSeq: number;
_appliedFetchSeq: number;
status: SessionStatus;
fetchedAt: number;
total: number;
@@ -120,6 +130,8 @@ function createEmptySlot(): SessionSlot {
hasMore: false,
offset: 0,
tokenUsage: null,
_fetchSeq: 0,
_appliedFetchSeq: 0,
};
}
@@ -459,6 +471,7 @@ export function useSessionStore() {
} = {},
) => {
const slot = getSlot(sessionId);
const fetchTicket = ++slot._fetchSeq;
slot.status = 'loading';
notify(sessionId);
@@ -481,6 +494,12 @@ export function useSessionStore() {
const data = body?.data ?? body;
const messages: NormalizedMessage[] = data.messages || [];
// A later-started fetch already applied: this response is stale.
if (fetchTicket <= slot._appliedFetchSeq) {
return slot;
}
slot._appliedFetchSeq = fetchTicket;
slot.serverMessages = messages;
slot.total = data.total ?? messages.length;
slot.hasMore = Boolean(data.hasMore);
@@ -496,8 +515,11 @@ export function useSessionStore() {
return slot;
} catch (error) {
console.error(`[SessionStore] fetch failed for ${sessionId}:`, error);
slot.status = 'error';
notify(sessionId);
// Don't clobber a newer fetch's result with a stale failure.
if (fetchTicket > slot._appliedFetchSeq) {
slot.status = 'error';
notify(sessionId);
}
return slot;
}
}, [getSlot, notify]);
@@ -514,6 +536,7 @@ export function useSessionStore() {
const slot = getSlot(sessionId);
if (!slot.hasMore) return slot;
const fetchTicket = ++slot._fetchSeq;
const params = new URLSearchParams();
const limit = opts.limit ?? 20;
params.append('limit', String(limit));
@@ -529,6 +552,13 @@ export function useSessionStore() {
const data = body?.data ?? body;
const olderMessages: NormalizedMessage[] = data.messages || [];
// A full fetch/refresh replaced serverMessages while this page was in
// flight — prepending onto the new array would duplicate or misorder.
if (fetchTicket <= slot._appliedFetchSeq) {
return slot;
}
slot._appliedFetchSeq = fetchTicket;
// Prepend older messages (they're earlier in the conversation)
slot.serverMessages = [...olderMessages, ...slot.serverMessages];
slot.hasMore = Boolean(data.hasMore);
@@ -588,6 +618,7 @@ export function useSessionStore() {
sessionId: string,
) => {
const slot = getSlot(sessionId);
const fetchTicket = ++slot._fetchSeq;
try {
const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages`;
const response = await authenticatedFetch(url);
@@ -596,6 +627,14 @@ export function useSessionStore() {
const body = await response.json();
const data = body?.data ?? body;
// A later-started fetch already applied: applying this stale transcript
// would erase rows the user has already seen (and re-prune realtime
// rows against an outdated snapshot).
if (fetchTicket <= slot._appliedFetchSeq) {
return;
}
slot._appliedFetchSeq = fetchTicket;
slot.serverMessages = data.messages || [];
slot.total = data.total ?? slot.serverMessages.length;
slot.hasMore = Boolean(data.hasMore);