mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-14 20:01:19 +08:00
Compare commits
3 Commits
17b8a9a561
...
fix/resolv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1d370d48b | ||
|
|
472c7a8055 | ||
|
|
6336d94fd6 |
@@ -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);
|
||||
|
||||
111
server/modules/providers/tests/codex-sessions.test.ts
Normal file
111
server/modules/providers/tests/codex-sessions.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 ------------
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
70
src/hooks/useQueuedMessageAutoSend.ts
Normal file
70
src/hooks/useQueuedMessageAutoSend.ts
Normal 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]);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user