Compare commits

..

4 Commits

Author SHA1 Message Date
Haile
e89d2da5df Fix New session issues and websocket issues (#738)
* fix: reset-state-on-new-session-click

* fix(chat): preserve continuity while session ids settle

New conversations were crossing a short but important consistency gap.
The route could already point at a newly created session id while the
projects payload had not refreshed yet, and realtime/optimistic messages
could still be keyed under a provisional id. In that window the UI could
stop reading the active session store, briefly render the conversation as
missing, and then repopulate it a moment later.

That same gap also made duplication more likely. Optimistic local user
messages could survive long enough to appear beside the persisted copy,
and finalized assistant streaming rows could sit directly next to the
server-backed assistant message with the same content before realtime state
was cleared. The result was a chat view that felt unstable exactly when a
new session was being created.

This commit makes session-id reconciliation a first-class part of the chat
flow instead of assuming every layer will agree immediately. The session
store now understands canonical session aliases and can migrate one
conversation from a provisional id to the real id without dropping its
in-memory state. The route navigation path can replace the provisional URL
entry instead of stacking it in history, and the project/session selection
logic keeps a synthetic selected session alive long enough for the sidebar
and project payloads to catch up.

The practical goal is to keep one visible conversation throughout the whole
creation lifecycle: no dead window between websocket events and project
refresh, no stale provisional URL after the real id is known, and no extra
optimistic/local bubbles when server history catches up.

* fix(cli): resolve executable path for Claude CLI on Windows

* fix(session-synchronizer): improve session name extraction for Claude and Codex
2026-05-04 13:54:07 +03:00
simosmik
392c73b693 fix: add clarification on auto mode 2026-04-30 15:28:00 +00:00
viper151
5e7c4c5f8c chore(release): v1.31.5 2026-04-30 14:12:48 +00:00
simosmik
3f71d4932b feat: add auto mode to claude code 2026-04-30 14:09:51 +00:00
29 changed files with 893 additions and 115 deletions

View File

@@ -3,6 +3,12 @@
All notable changes to CloudCLI UI will be documented in this file. All notable changes to CloudCLI UI will be documented in this file.
## [1.31.5](https://github.com/siteboon/claudecodeui/compare/v1.31.4...v1.31.5) (2026-04-30)
### New Features
* add auto mode to claude code ([3f71d49](https://github.com/siteboon/claudecodeui/commit/3f71d4932b05dfedcdf816e2a3d7d0cd69c4f566))
## [1.31.4](https://github.com/siteboon/claudecodeui/compare/v1.31.3...v1.31.4) (2026-04-30) ## [1.31.4](https://github.com/siteboon/claudecodeui/compare/v1.31.3...v1.31.4) (2026-04-30)
### Bug Fixes ### Bug Fixes

View File

@@ -157,7 +157,7 @@ export default tseslint.config(
}, },
{ {
type: "backend-shared-utils", // shared backend runtime helpers that modules may import directly type: "backend-shared-utils", // shared backend runtime helpers that modules may import directly
pattern: ["server/shared/utils.{js,ts}"], // classify the shared utils file so modules can depend on it explicitly pattern: ["server/shared/utils.{js,ts}", "server/shared/claude-cli-path.ts"], // classify the shared utils file so modules can depend on it explicitly
mode: "file", mode: "file",
}, },
{ {

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@cloudcli-ai/cloudcli", "name": "@cloudcli-ai/cloudcli",
"version": "1.31.4", "version": "1.31.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@cloudcli-ai/cloudcli", "name": "@cloudcli-ai/cloudcli",
"version": "1.31.4", "version": "1.31.5",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@cloudcli-ai/cloudcli", "name": "@cloudcli-ai/cloudcli",
"version": "1.31.4", "version": "1.31.5",
"description": "A web-based UI for Claude Code CLI", "description": "A web-based UI for Claude Code CLI",
"type": "module", "type": "module",
"main": "dist-server/server/index.js", "main": "dist-server/server/index.js",

View File

@@ -18,6 +18,7 @@ import { promises as fs } from 'fs';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
import { CLAUDE_MODELS } from '../shared/modelConstants.js'; import { CLAUDE_MODELS } from '../shared/modelConstants.js';
import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js';
import { import {
createNotificationEvent, createNotificationEvent,
notifyRunFailed, notifyRunFailed,
@@ -153,11 +154,9 @@ function mapCliOptionsToSDK(options = {}) {
// Since SDK 0.2.113, options.env replaces process.env instead of overlaying it. // Since SDK 0.2.113, options.env replaces process.env instead of overlaying it.
sdkOptions.env = { ...process.env }; sdkOptions.env = { ...process.env };
// Use CLAUDE_CLI_PATH if explicitly set, otherwise fall back to 'claude' on PATH. // Resolve the executable eagerly on Windows because the SDK uses raw child_process.spawn,
// The SDK 0.2.113+ looks for a bundled native binary optional dep by default; // which does not reliably follow npm's shell wrappers like cross-spawn does.
// this fallback ensures users who installed via the official installer still work sdkOptions.pathToClaudeCodeExecutable = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH);
// even when npm prune --production has removed those optional deps.
sdkOptions.pathToClaudeCodeExecutable = process.env.CLAUDE_CLI_PATH || 'claude';
// Map working directory // Map working directory
if (cwd) { if (cwd) {
@@ -527,6 +526,12 @@ async function queryClaudeSDK(command, options = {}, ws) {
}] }]
}; };
// Caveat: in 'auto' and 'bypassPermissions' modes the SDK resolves approval
// at the permission-mode step and skips this callback, so interactive tools
// (AskUserQuestion, ExitPlanMode) won't reach the UI — the classifier/bypass
// auto-approves them and the model acts on a generated answer. Move these
// tools to a PreToolUse hook (runs before the mode check) if we need them
// to work in those modes.
sdkOptions.canUseTool = async (toolName, input, context) => { sdkOptions.canUseTool = async (toolName, input, context) => {
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName); const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);

View File

@@ -4,6 +4,7 @@ import path from 'node:path';
import spawn from 'cross-spawn'; import spawn from 'cross-spawn';
import { resolveClaudeCodeExecutablePath } from '@/shared/claude-cli-path.js';
import type { IProviderAuth } from '@/shared/interfaces.js'; import type { IProviderAuth } from '@/shared/interfaces.js';
import type { ProviderAuthStatus } from '@/shared/types.js'; import type { ProviderAuthStatus } from '@/shared/types.js';
import { readObjectRecord, readOptionalString } from '@/shared/utils.js'; import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
@@ -20,13 +21,13 @@ export class ClaudeProviderAuth implements IProviderAuth {
* Checks whether the Claude Code CLI is available on this host. * Checks whether the Claude Code CLI is available on this host.
*/ */
private checkInstalled(): boolean { private checkInstalled(): boolean {
const cliPath = process.env.CLAUDE_CLI_PATH || 'claude'; const cliPath = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH);
try { try {
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 }); spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
return true; return true;
} catch { } catch {
return false; return false;
} }
} }
/** /**

View File

@@ -1,5 +1,6 @@
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { readFile } from 'node:fs/promises';
import { sessionsDb } from '@/modules/database/index.js'; import { sessionsDb } from '@/modules/database/index.js';
import { import {
@@ -91,7 +92,7 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
filePath: string, filePath: string,
nameMap: Map<string, string> nameMap: Map<string, string>
): Promise<ParsedSession | null> { ): Promise<ParsedSession | null> {
return extractFirstValidJsonlData(filePath, (rawData) => { const parsed = await extractFirstValidJsonlData(filePath, (rawData) => {
const data = rawData as Record<string, unknown>; const data = rawData as Record<string, unknown>;
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined; const sessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined;
const projectPath = typeof data.cwd === 'string' ? data.cwd : undefined; const projectPath = typeof data.cwd === 'string' ? data.cwd : undefined;
@@ -103,8 +104,68 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
return { return {
sessionId, sessionId,
projectPath, projectPath,
sessionName: normalizeSessionName(nameMap.get(sessionId), 'Untitled Claude Session'),
}; };
}); });
if (!parsed) {
return null;
}
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
const existingSessionName = existingSession?.custom_name;
if (existingSessionName && existingSessionName !== 'Untitled Claude Session') {
return {
...parsed,
sessionName: normalizeSessionName(existingSessionName, 'Untitled Claude Session'),
};
}
let sessionName = nameMap.get(parsed.sessionId);
if (!sessionName) {
sessionName = await this.extractSessionAiTitleFromEnd(filePath, parsed.sessionId);
}
return {
...parsed,
sessionName: normalizeSessionName(sessionName, 'Untitled Claude Session'),
};
}
private async extractSessionAiTitleFromEnd(
filePath: string,
sessionId: string
): Promise<string | undefined> {
try {
const content = await readFile(filePath, 'utf8');
const lines = content.split(/\r?\n/);
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index]?.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 eventSessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined;
const aiTitle = typeof data.aiTitle === 'string' ? data.aiTitle : undefined;
const lastPrompt = typeof data.lastPrompt === 'string' ? data.lastPrompt : undefined;
if ((eventType === 'ai-title' && eventSessionId === sessionId && aiTitle?.trim()) || (eventType === 'last-prompt' && eventSessionId === sessionId && lastPrompt?.trim())) {
return aiTitle || lastPrompt;
}
}
} catch {
// Ignore missing/unreadable files so sync can continue.
}
return undefined;
} }
} }

View File

@@ -1,5 +1,6 @@
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { readFile } from 'node:fs/promises';
import { sessionsDb } from '@/modules/database/index.js'; import { sessionsDb } from '@/modules/database/index.js';
import { import {
@@ -99,7 +100,7 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
filePath: string, filePath: string,
nameMap: Map<string, string> nameMap: Map<string, string>
): Promise<ParsedSession | null> { ): Promise<ParsedSession | null> {
return extractFirstValidJsonlData(filePath, (rawData) => { const parsed = await extractFirstValidJsonlData(filePath, (rawData) => {
const data = rawData as Record<string, unknown>; const data = rawData as Record<string, unknown>;
const payload = data.payload as Record<string, unknown> | undefined; const payload = data.payload as Record<string, unknown> | undefined;
const sessionId = typeof payload?.id === 'string' ? payload.id : undefined; const sessionId = typeof payload?.id === 'string' ? payload.id : undefined;
@@ -112,8 +113,67 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
return { return {
sessionId, sessionId,
projectPath, projectPath,
sessionName: normalizeSessionName(nameMap.get(sessionId), 'Untitled Codex Session'),
}; };
}); });
if (!parsed) {
return null;
}
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
const existingSessionName = existingSession?.custom_name;
if (existingSessionName && existingSessionName !== 'Untitled Codex Session') {
return {
...parsed,
sessionName: normalizeSessionName(existingSessionName, 'Untitled Codex Session'),
};
}
let sessionName = nameMap.get(parsed.sessionId);
if (!sessionName) {
sessionName = await this.extractLastAgentMessageFromEnd(filePath);
}
return {
...parsed,
sessionName: normalizeSessionName(sessionName, 'Untitled Codex Session'),
};
}
private async extractLastAgentMessageFromEnd(filePath: string): Promise<string | undefined> {
try {
const content = await readFile(filePath, 'utf8');
const lines = content.split(/\r?\n/);
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index]?.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 lastAgentMessage = typeof payload?.last_agent_message === 'string'
? payload.last_agent_message
: undefined;
if (eventType === 'event_msg' && payloadType === 'task_complete' && lastAgentMessage?.trim()) {
return lastAgentMessage;
}
}
} catch {
// Ignore missing/unreadable files so sync can continue.
}
return undefined;
} }
} }

View File

@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
resolveClaudeCodeExecutablePath,
type ResolveClaudeCodeExecutablePathDependencies,
} from '@/shared/claude-cli-path.js';
test('resolveClaudeCodeExecutablePath resolves the npm Claude wrapper to its native exe on Windows', () => {
const wrapperDir = 'C:\\nvm4w\\nodejs';
const nativePath = `${wrapperDir}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`;
const execFileSync =
(() => `${wrapperDir}\\claude\r\n${wrapperDir}\\claude.cmd\r\n`) as unknown as ResolveClaudeCodeExecutablePathDependencies['execFileSync'];
const readFileSync = (() => '') as unknown as ResolveClaudeCodeExecutablePathDependencies['readFileSync'];
const resolved = resolveClaudeCodeExecutablePath('claude', {
platform: 'win32',
execFileSync,
existsSync: (candidate) => candidate === nativePath,
readFileSync,
});
assert.equal(resolved, nativePath);
});
test('resolveClaudeCodeExecutablePath keeps an explicit JavaScript launcher path unchanged', () => {
const scriptPath = 'C:\\tools\\claude.js';
const resolved = resolveClaudeCodeExecutablePath(scriptPath, {
platform: 'win32',
});
assert.equal(resolved, scriptPath);
});
test('resolveClaudeCodeExecutablePath falls back to the configured command when PATH lookup fails', () => {
const execFileSync = (() => {
throw new Error('not found');
}) as unknown as ResolveClaudeCodeExecutablePathDependencies['execFileSync'];
const resolved = resolveClaudeCodeExecutablePath('claude', {
platform: 'win32',
execFileSync,
});
assert.equal(resolved, 'claude');
});

View File

@@ -0,0 +1,139 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const DEFAULT_CLAUDE_COMMAND = 'claude';
const CLAUDE_SCRIPT_EXTENSIONS = new Set(['.cjs', '.js', '.jsx', '.mjs', '.ts', '.tsx']);
const CLAUDE_WRAPPER_SEGMENTS = ['node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'] as const;
export type ResolveClaudeCodeExecutablePathDependencies = {
execFileSync?: typeof execFileSync;
existsSync?: typeof fs.existsSync;
platform?: NodeJS.Platform;
readFileSync?: typeof fs.readFileSync;
};
function getPathApi(platform: NodeJS.Platform) {
return platform === 'win32' ? path.win32 : path;
}
function stripWrappingQuotes(value: string): string {
const trimmed = value.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function isPathLike(value: string): boolean {
return value.includes('/') || value.includes('\\');
}
function resolveClaudeWrapperBinary(
wrapperPath: string,
deps: Required<ResolveClaudeCodeExecutablePathDependencies>,
): string | null {
const pathApi = getPathApi(deps.platform);
const directCandidate = pathApi.resolve(pathApi.dirname(wrapperPath), ...CLAUDE_WRAPPER_SEGMENTS);
if (deps.existsSync(directCandidate)) {
return directCandidate;
}
let content: string;
try {
content = deps.readFileSync(wrapperPath, 'utf8');
} catch {
return null;
}
const matches = content.matchAll(/["']([^"'\\r\\n]*claude\.exe)["']/gi);
for (const match of matches) {
const rawTarget = match[1]
.replace(/^\$basedir[\\/]/i, '')
.replace(/^%dp0%[\\/]/i, '')
.replace(/^%~dp0[\\/]/i, '');
const normalizedTarget = rawTarget.replace(/[\\/]/g, pathApi.sep);
const candidate = pathApi.isAbsolute(normalizedTarget)
? normalizedTarget
: pathApi.resolve(pathApi.dirname(wrapperPath), normalizedTarget);
if (deps.existsSync(candidate)) {
return candidate;
}
}
return null;
}
function resolveWindowsClaudeExecutablePath(
configuredPath: string,
deps: Required<ResolveClaudeCodeExecutablePathDependencies>,
): string {
const pathApi = getPathApi(deps.platform);
const extension = pathApi.extname(configuredPath).toLowerCase();
const explicitPath = isPathLike(configuredPath) || pathApi.isAbsolute(configuredPath);
if (CLAUDE_SCRIPT_EXTENSIONS.has(extension)) {
return configuredPath;
}
if (explicitPath && extension === '.exe') {
return configuredPath;
}
if (explicitPath) {
return resolveClaudeWrapperBinary(configuredPath, deps) ?? configuredPath;
}
try {
const stdout = deps.execFileSync('where.exe', [configuredPath], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
});
const candidates = stdout
.split(/\r?\n/)
.map((entry) => entry.trim())
.filter(Boolean);
for (const candidate of candidates) {
if (pathApi.extname(candidate).toLowerCase() === '.exe') {
return candidate;
}
}
for (const candidate of candidates) {
const resolved = resolveClaudeWrapperBinary(candidate, deps);
if (resolved) {
return resolved;
}
}
} catch {
return configuredPath;
}
return configuredPath;
}
export function resolveClaudeCodeExecutablePath(
configuredPath: string | undefined = process.env.CLAUDE_CLI_PATH,
dependencies: ResolveClaudeCodeExecutablePathDependencies = {},
): string {
const deps: Required<ResolveClaudeCodeExecutablePathDependencies> = {
execFileSync: dependencies.execFileSync ?? execFileSync,
existsSync: dependencies.existsSync ?? fs.existsSync,
platform: dependencies.platform ?? process.platform,
readFileSync: dependencies.readFileSync ?? fs.readFileSync,
};
const normalizedPath = stripWrappingQuotes(configuredPath || DEFAULT_CLAUDE_COMMAND);
if (deps.platform !== 'win32') {
return normalizedPath;
}
return resolveWindowsClaudeExecutablePath(normalizedPath, deps);
}

View File

@@ -44,6 +44,7 @@ function AppContentInner() {
sidebarOpen, sidebarOpen,
isLoadingProjects, isLoadingProjects,
externalMessageUpdate, externalMessageUpdate,
newSessionTrigger,
setActiveTab, setActiveTab,
setSidebarOpen, setSidebarOpen,
setIsInputFocused, setIsInputFocused,
@@ -191,9 +192,12 @@ function AppContentInner() {
onSessionNotProcessing={markSessionAsNotProcessing} onSessionNotProcessing={markSessionAsNotProcessing}
processingSessions={processingSessions} processingSessions={processingSessions}
onReplaceTemporarySession={replaceTemporarySession} onReplaceTemporarySession={replaceTemporarySession}
onNavigateToSession={(targetSessionId: string) => navigate(`/session/${targetSessionId}`)} onNavigateToSession={(targetSessionId: string, options) =>
navigate(`/session/${targetSessionId}`, { replace: Boolean(options?.replace) })
}
onShowSettings={() => setShowSettings(true)} onShowSettings={() => setShowSettings(true)}
externalMessageUpdate={externalMessageUpdate} externalMessageUpdate={externalMessageUpdate}
newSessionTrigger={newSessionTrigger}
/> />
</div> </div>

View File

@@ -4,6 +4,16 @@ import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS, GEMINI_MODELS } from '../..
import type { PendingPermissionRequest, PermissionMode } from '../types/types'; import type { PendingPermissionRequest, PermissionMode } from '../types/types';
import type { ProjectSession, LLMProvider } from '../../../types/app'; import type { ProjectSession, LLMProvider } from '../../../types/app';
const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[] => {
if (provider === 'codex') {
return ['default', 'acceptEdits', 'bypassPermissions'];
}
if (provider === 'claude') {
return ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'];
}
return ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
};
interface UseChatProviderStateArgs { interface UseChatProviderStateArgs {
selectedSession: ProjectSession | null; selectedSession: ProjectSession | null;
} }
@@ -34,9 +44,10 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
return; return;
} }
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`); const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null;
setPermissionMode((savedMode as PermissionMode) || 'default'); const validModes = getPermissionModesForProvider(provider);
}, [selectedSession?.id]); setPermissionMode(savedMode && validModes.includes(savedMode) ? savedMode : 'default');
}, [selectedSession?.id, provider]);
useEffect(() => { useEffect(() => {
if (!selectedSession?.__provider || selectedSession.__provider === provider) { if (!selectedSession?.__provider || selectedSession.__provider === provider) {
@@ -84,10 +95,7 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
}, [provider]); }, [provider]);
const cyclePermissionMode = useCallback(() => { const cyclePermissionMode = useCallback(() => {
const modes: PermissionMode[] = const modes = getPermissionModesForProvider(provider);
provider === 'codex'
? ['default', 'acceptEdits', 'bypassPermissions']
: ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
const currentIndex = modes.indexOf(permissionMode); const currentIndex = modes.indexOf(permissionMode);
const nextIndex = (currentIndex + 1) % modes.length; const nextIndex = (currentIndex + 1) % modes.length;

View File

@@ -1,7 +1,8 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { usePaletteOps } from '../../../contexts/PaletteOpsContext'; import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
import type { PendingPermissionRequest } from '../types/types'; import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types';
import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
@@ -67,7 +68,7 @@ interface UseChatRealtimeHandlersArgs {
onSessionProcessing?: (sessionId?: string | null) => void; onSessionProcessing?: (sessionId?: string | null) => void;
onSessionNotProcessing?: (sessionId?: string | null) => void; onSessionNotProcessing?: (sessionId?: string | null) => void;
onReplaceTemporarySession?: (sessionId?: string | null) => void; onReplaceTemporarySession?: (sessionId?: string | null) => void;
onNavigateToSession?: (sessionId: string) => void; onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void;
onWebSocketReconnect?: () => void; onWebSocketReconnect?: () => void;
sessionStore: SessionStore; sessionStore: SessionStore;
} }
@@ -273,13 +274,53 @@ export function useChatRealtimeHandlers({
break; break;
} }
// Clear pending session const actualSessionId =
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
? msg.actualSessionId
: null;
const pendingSessionId = sessionStorage.getItem('pendingSessionId'); const pendingSessionId = sessionStorage.getItem('pendingSessionId');
if (pendingSessionId && !currentSessionId && msg.exitCode === 0) { const completedSuccessfully = msg.exitCode === undefined || msg.exitCode === 0;
const actualId = msg.actualSessionId || pendingSessionId; const isVisibleSession =
setCurrentSessionId(actualId); Boolean(
if (msg.actualSessionId) { sid
onNavigateToSession?.(actualId); && (
sid === activeViewSessionId
|| sid === pendingSessionId
|| pendingViewSessionRef.current?.sessionId === sid
),
);
if (actualSessionId && sid && actualSessionId !== sid) {
sessionStore.replaceSessionId(sid, actualSessionId);
if (isVisibleSession) {
setCurrentSessionId(actualSessionId);
if (pendingViewSessionRef.current) {
const pendingSession = pendingViewSessionRef.current.sessionId;
if (!pendingSession || pendingSession === sid) {
pendingViewSessionRef.current.sessionId = actualSessionId;
}
}
}
if (completedSuccessfully && pendingSessionId === sid) {
sessionStorage.removeItem('pendingSessionId');
}
if (isVisibleSession) {
onNavigateToSession?.(actualSessionId, { replace: true });
setTimeout(() => { void paletteOps.refreshProjects(); }, 500);
}
break;
}
// Clear pending session
if (pendingSessionId && !currentSessionId && completedSuccessfully) {
const resolvedSessionId = actualSessionId || pendingSessionId;
setCurrentSessionId(resolvedSessionId);
if (actualSessionId) {
onNavigateToSession?.(resolvedSessionId, { replace: true });
} }
sessionStorage.removeItem('pendingSessionId'); sessionStorage.removeItem('pendingSessionId');
setTimeout(() => { void paletteOps.refreshProjects(); }, 500); setTimeout(() => { void paletteOps.refreshProjects(); }, 500);

View File

@@ -1,11 +1,13 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { MutableRefObject } from 'react'; import type { MutableRefObject } from 'react';
import { authenticatedFetch } from '../../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
import type { ChatMessage, Provider } from '../types/types';
import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms';
import { normalizedToChatMessages } from './useChatMessages';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
import type { ChatMessage, Provider } from '../types/types';
import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms';
import { normalizedToChatMessages } from './useChatMessages';
const MESSAGES_PER_PAGE = 20; const MESSAGES_PER_PAGE = 20;
const INITIAL_VISIBLE_MESSAGES = 100; const INITIAL_VISIBLE_MESSAGES = 100;
@@ -22,6 +24,7 @@ interface UseChatSessionStateArgs {
sendMessage: (message: unknown) => void; sendMessage: (message: unknown) => void;
autoScrollToBottom?: boolean; autoScrollToBottom?: boolean;
externalMessageUpdate?: number; externalMessageUpdate?: number;
newSessionTrigger?: number;
processingSessions?: Set<string>; processingSessions?: Set<string>;
resetStreamingState: () => void; resetStreamingState: () => void;
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>; pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
@@ -95,6 +98,7 @@ export function useChatSessionState({
sendMessage, sendMessage,
autoScrollToBottom, autoScrollToBottom,
externalMessageUpdate, externalMessageUpdate,
newSessionTrigger,
processingSessions, processingSessions,
resetStreamingState, resetStreamingState,
pendingViewSessionRef, pendingViewSessionRef,
@@ -131,15 +135,85 @@ export function useChatSessionState({
const loadAllFinishedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const loadAllFinishedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadAllOverlayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const loadAllOverlayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastLoadedSessionKeyRef = useRef<string | null>(null); const lastLoadedSessionKeyRef = useRef<string | null>(null);
/**
* Tracks the last processed value from `useProjectsState.newSessionTrigger`.
*
* The trigger itself is intentionally increment-only and routed via:
* useProjectsState -> AppContent -> MainContent -> ChatInterface -> this hook.
* We compare values to ensure each explicit New Session click runs exactly one
* reset pass in this local chat state domain.
*/
const previousNewSessionTriggerRef = useRef(newSessionTrigger ?? 0);
const createDiff = useMemo<DiffCalculator>(() => createCachedDiffCalculator(), []); const createDiff = useMemo<DiffCalculator>(() => createCachedDiffCalculator(), []);
useEffect(() => {
const trigger = newSessionTrigger ?? 0;
if (trigger === previousNewSessionTriggerRef.current) {
return;
}
previousNewSessionTriggerRef.current = trigger;
/**
* Consumer-side reset for explicit New Session intent.
*
* Why this is essential:
* - Chat keeps local state that is not fully derived from `selectedSession`:
* `currentSessionId`, `pendingUserMessage`, streaming/status flags, message
* pagination/scroll bookkeeping, and pending session IDs in sessionStorage.
* - If the user clicks New Session while already on the same route with no
* selected session, parent state updates can be idempotent and this local
* state would otherwise persist, making the click appear to "do nothing".
*
* What this reset guarantees:
* - A deterministic clean draft state on every New Session click.
* - No dependence on route/tab/session-object identity changes.
* - No coupling to unrelated external update signals.
*/
resetStreamingState();
pendingViewSessionRef.current = null;
setClaudeStatus(null);
setCanAbortSession(false);
setIsLoading(false);
setCurrentSessionId(null);
setPendingUserMessage(null);
sessionStorage.removeItem('pendingSessionId');
sessionStorage.removeItem('cursorSessionId');
messagesOffsetRef.current = 0;
setHasMoreMessages(false);
setTotalMessages(0);
setTokenBudget(null);
setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);
setAllMessagesLoaded(false);
allMessagesLoadedRef.current = false;
setIsLoadingAllMessages(false);
setLoadAllJustFinished(false);
setShowLoadAllOverlay(false);
setViewHiddenCount(0);
setSearchTarget(null);
searchScrollActiveRef.current = false;
topLoadLockRef.current = false;
pendingScrollRestoreRef.current = null;
pendingInitialScrollRef.current = true;
lastLoadedSessionKeyRef.current = null;
if (loadAllOverlayTimerRef.current) {
clearTimeout(loadAllOverlayTimerRef.current);
loadAllOverlayTimerRef.current = null;
}
if (loadAllFinishedTimerRef.current) {
clearTimeout(loadAllFinishedTimerRef.current);
loadAllFinishedTimerRef.current = null;
}
}, [newSessionTrigger, pendingViewSessionRef, resetStreamingState]);
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
/* Derive chatMessages from the store */ /* Derive chatMessages from the store */
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
const activeSessionId = selectedSession?.id || currentSessionId || null; const activeSessionId = selectedSession?.id || currentSessionId || null;
const [pendingUserMessage, setPendingUserMessage] = useState<ChatMessage | null>(null); const [pendingUserMessage, setPendingUserMessage] = useState<ChatMessage | null>(null);
const flushedPendingUserMessageRef = useRef<ChatMessage | null>(null);
// Tell the store which session we're viewing so it only re-renders for this one // Tell the store which session we're viewing so it only re-renders for this one
const prevActiveForStoreRef = useRef<string | null>(null); const prevActiveForStoreRef = useRef<string | null>(null);
@@ -148,17 +222,29 @@ export function useChatSessionState({
sessionStore.setActiveSession(activeSessionId); sessionStore.setActiveSession(activeSessionId);
} }
// When a real session ID arrives and we have a pending user message, flush it to the store useEffect(() => {
const prevActiveSessionRef = useRef<string | null>(null); if (!pendingUserMessage) {
if (activeSessionId && activeSessionId !== prevActiveSessionRef.current && pendingUserMessage) { flushedPendingUserMessageRef.current = null;
return;
}
if (!activeSessionId) {
return;
}
if (flushedPendingUserMessageRef.current === pendingUserMessage) {
return;
}
const prov = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude'; const prov = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
const normalized = chatMessageToNormalized(pendingUserMessage, activeSessionId, prov); const normalized = chatMessageToNormalized(pendingUserMessage, activeSessionId, prov);
if (normalized) { if (normalized) {
sessionStore.appendRealtime(activeSessionId, normalized); sessionStore.appendRealtime(activeSessionId, normalized);
} }
flushedPendingUserMessageRef.current = pendingUserMessage;
setPendingUserMessage(null); setPendingUserMessage(null);
} }, [activeSessionId, pendingUserMessage, sessionStore]);
prevActiveSessionRef.current = activeSessionId;
const storeMessages = activeSessionId ? sessionStore.getMessages(activeSessionId) : []; const storeMessages = activeSessionId ? sessionStore.getMessages(activeSessionId) : [];

View File

@@ -2,7 +2,7 @@ import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
export type Provider = LLMProvider; export type Provider = LLMProvider;
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'; export type PermissionMode = 'default' | 'acceptEdits' | 'auto' | 'bypassPermissions' | 'plan';
export interface ChatImage { export interface ChatImage {
data: string; data: string;
@@ -91,6 +91,10 @@ export interface Question {
multiSelect?: boolean; multiSelect?: boolean;
} }
export type SessionNavigationOptions = {
replace?: boolean;
};
export interface ChatInterfaceProps { export interface ChatInterfaceProps {
selectedProject: Project | null; selectedProject: Project | null;
selectedSession: ProjectSession | null; selectedSession: ProjectSession | null;
@@ -105,7 +109,7 @@ export interface ChatInterfaceProps {
onSessionNotProcessing?: (sessionId?: string | null) => void; onSessionNotProcessing?: (sessionId?: string | null) => void;
processingSessions?: Set<string>; processingSessions?: Set<string>;
onReplaceTemporarySession?: (sessionId?: string | null) => void; onReplaceTemporarySession?: (sessionId?: string | null) => void;
onNavigateToSession?: (targetSessionId: string) => void; onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void;
onShowSettings?: () => void; onShowSettings?: () => void;
autoExpandTools?: boolean; autoExpandTools?: boolean;
showRawParameters?: boolean; showRawParameters?: boolean;
@@ -113,6 +117,7 @@ export interface ChatInterfaceProps {
autoScrollToBottom?: boolean; autoScrollToBottom?: boolean;
sendByCtrlEnter?: boolean; sendByCtrlEnter?: boolean;
externalMessageUpdate?: number; externalMessageUpdate?: number;
newSessionTrigger?: number;
onTaskClick?: (...args: unknown[]) => void; onTaskClick?: (...args: unknown[]) => void;
onShowAllTasks?: (() => void) | null; onShowAllTasks?: (() => void) | null;
} }

View File

@@ -43,6 +43,7 @@ function ChatInterface({
autoScrollToBottom, autoScrollToBottom,
sendByCtrlEnter, sendByCtrlEnter,
externalMessageUpdate, externalMessageUpdate,
newSessionTrigger,
onShowAllTasks, onShowAllTasks,
}: ChatInterfaceProps) { }: ChatInterfaceProps) {
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings(); const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
@@ -123,6 +124,7 @@ function ChatInterface({
sendMessage, sendMessage,
autoScrollToBottom, autoScrollToBottom,
externalMessageUpdate, externalMessageUpdate,
newSessionTrigger,
processingSessions, processingSessions,
resetStreamingState, resetStreamingState,
pendingViewSessionRef, pendingViewSessionRef,

View File

@@ -325,9 +325,11 @@ export default function ChatComposer({
? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted' ? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted'
: permissionMode === 'acceptEdits' : permissionMode === 'acceptEdits'
? 'border-green-300/60 bg-green-50 text-green-700 hover:bg-green-100 dark:border-green-600/40 dark:bg-green-900/15 dark:text-green-300 dark:hover:bg-green-900/25' ? 'border-green-300/60 bg-green-50 text-green-700 hover:bg-green-100 dark:border-green-600/40 dark:bg-green-900/15 dark:text-green-300 dark:hover:bg-green-900/25'
: permissionMode === 'bypassPermissions' : permissionMode === 'auto'
? 'border-orange-300/60 bg-orange-50 text-orange-700 hover:bg-orange-100 dark:border-orange-600/40 dark:bg-orange-900/15 dark:text-orange-300 dark:hover:bg-orange-900/25' ? 'border-blue-300/60 bg-blue-50 text-blue-700 hover:bg-blue-100 dark:border-blue-600/40 dark:bg-blue-900/15 dark:text-blue-300 dark:hover:bg-blue-900/25'
: 'border-primary/20 bg-primary/5 text-primary hover:bg-primary/10' : permissionMode === 'bypassPermissions'
? 'border-orange-300/60 bg-orange-50 text-orange-700 hover:bg-orange-100 dark:border-orange-600/40 dark:bg-orange-900/15 dark:text-orange-300 dark:hover:bg-orange-900/25'
: 'border-primary/20 bg-primary/5 text-primary hover:bg-primary/10'
}`} }`}
title={t('input.clickToChangeMode')} title={t('input.clickToChangeMode')}
> >
@@ -338,14 +340,17 @@ export default function ChatComposer({
? 'bg-muted-foreground' ? 'bg-muted-foreground'
: permissionMode === 'acceptEdits' : permissionMode === 'acceptEdits'
? 'bg-green-500' ? 'bg-green-500'
: permissionMode === 'bypassPermissions' : permissionMode === 'auto'
? 'bg-orange-500' ? 'bg-blue-500'
: 'bg-primary' : permissionMode === 'bypassPermissions'
? 'bg-orange-500'
: 'bg-primary'
}`} }`}
/> />
<span className="hidden whitespace-nowrap sm:inline"> <span className="hidden whitespace-nowrap sm:inline">
{permissionMode === 'default' && t('codex.modes.default')} {permissionMode === 'default' && t('codex.modes.default')}
{permissionMode === 'acceptEdits' && t('codex.modes.acceptEdits')} {permissionMode === 'acceptEdits' && t('codex.modes.acceptEdits')}
{permissionMode === 'auto' && t('codex.modes.auto')}
{permissionMode === 'bypassPermissions' && t('codex.modes.bypassPermissions')} {permissionMode === 'bypassPermissions' && t('codex.modes.bypassPermissions')}
{permissionMode === 'plan' && t('codex.modes.plan')} {permissionMode === 'plan' && t('codex.modes.plan')}
</span> </span>

View File

@@ -1,5 +1,7 @@
import type { Dispatch, SetStateAction } from 'react'; import type { Dispatch, SetStateAction } from 'react';
import type { AppTab, Project, ProjectSession } from '../../../types/app'; import type { AppTab, Project, ProjectSession } from '../../../types/app';
import type { SessionNavigationOptions } from '../../chat/types/types';
export type SessionLifecycleHandler = (sessionId?: string | null) => void; export type SessionLifecycleHandler = (sessionId?: string | null) => void;
@@ -50,9 +52,10 @@ export type MainContentProps = {
onSessionNotProcessing: SessionLifecycleHandler; onSessionNotProcessing: SessionLifecycleHandler;
processingSessions: Set<string>; processingSessions: Set<string>;
onReplaceTemporarySession: SessionLifecycleHandler; onReplaceTemporarySession: SessionLifecycleHandler;
onNavigateToSession: (targetSessionId: string) => void; onNavigateToSession: (targetSessionId: string, options?: SessionNavigationOptions) => void;
onShowSettings: () => void; onShowSettings: () => void;
externalMessageUpdate: number; externalMessageUpdate: number;
newSessionTrigger: number;
}; };
export type MainContentHeaderProps = { export type MainContentHeaderProps = {

View File

@@ -51,6 +51,7 @@ function MainContent({
onNavigateToSession, onNavigateToSession,
onShowSettings, onShowSettings,
externalMessageUpdate, externalMessageUpdate,
newSessionTrigger,
}: MainContentProps) { }: MainContentProps) {
const { preferences } = useUiPreferences(); const { preferences } = useUiPreferences();
const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences; const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences;
@@ -145,6 +146,7 @@ function MainContent({
autoScrollToBottom={autoScrollToBottom} autoScrollToBottom={autoScrollToBottom}
sendByCtrlEnter={sendByCtrlEnter} sendByCtrlEnter={sendByCtrlEnter}
externalMessageUpdate={externalMessageUpdate} externalMessageUpdate={externalMessageUpdate}
newSessionTrigger={newSessionTrigger}
onShowAllTasks={tasksEnabled ? () => setActiveTab('tasks') : null} onShowAllTasks={tasksEnabled ? () => setActiveTab('tasks') : null}
/> />
</ErrorBoundary> </ErrorBoundary>

View File

@@ -5,6 +5,7 @@ import { api } from '../utils/api';
import type { import type {
AppSocketMessage, AppSocketMessage,
AppTab, AppTab,
LLMProvider,
LoadingProgress, LoadingProgress,
Project, Project,
ProjectSession, ProjectSession,
@@ -261,6 +262,27 @@ export function useProjectsState({
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [settingsInitialTab, setSettingsInitialTab] = useState('agents'); const [settingsInitialTab, setSettingsInitialTab] = useState('agents');
const [externalMessageUpdate, setExternalMessageUpdate] = useState(0); const [externalMessageUpdate, setExternalMessageUpdate] = useState(0);
/**
* `newSessionTrigger` is an explicit, monotonic intent signal for user-driven
* New Session actions.
*
* It exists because `handleNewSession` can be invoked while the app is already in
* the same visible state (`selectedSession === null`, `activeTab === 'chat'`,
* route already `/`). In that case, React/router updates are idempotent and no
* downstream reset logic runs.
*
* Usage across the codebase:
* 1) Produced here in `handleNewSession` via increment (always changes).
* 2) Returned from this hook and threaded through:
* useProjectsState -> AppContent -> MainContent -> ChatInterface.
* 3) Consumed in `useChatSessionState` as an effect dependency to forcibly clear
* chat-local state (`currentSessionId`, pending draft message, streaming flags,
* pending session storage keys, pagination/scroll artifacts).
*
* Keeping this signal dedicated avoids coupling resets to unrelated counters/events
* (for example websocket/project refresh updates) that could cause accidental resets.
*/
const [newSessionTrigger, setNewSessionTrigger] = useState(0);
const loadingProgressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const loadingProgressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastHandledMessageRef = useRef<AppSocketMessage | null>(null); const lastHandledMessageRef = useRef<AppSocketMessage | null>(null);
@@ -536,7 +558,42 @@ export function useProjectsState({
return; return;
} }
} }
}, [sessionId, projects, selectedProject?.projectId, selectedSession?.id, selectedSession?.__provider]);
// Session id is in the URL but not yet present on any project payload (common
// right after `session_created` + navigate, before the next projects refresh).
// Without a `selectedSession`, chat state clears `currentSessionId` and the
// UI stops reading the session store even though messages stream under this id.
if (selectedSession?.id === sessionId) {
return;
}
if (!selectedProject) {
return;
}
let providerFromStorage: string | null = null;
try {
providerFromStorage = localStorage.getItem('selected-provider');
} catch {
providerFromStorage = null;
}
const normalizedProvider: LLMProvider =
providerFromStorage === 'cursor'
? 'cursor'
: providerFromStorage === 'codex'
? 'codex'
: providerFromStorage === 'gemini'
? 'gemini'
: 'claude';
setSelectedSession({
id: sessionId,
__provider: normalizedProvider,
__projectId: selectedProject.projectId,
summary: '',
});
}, [sessionId, projects, selectedProject, selectedSession?.id, selectedSession?.__provider]);
const handleProjectSelect = useCallback( const handleProjectSelect = useCallback(
(project: Project) => { (project: Project) => {
@@ -587,6 +644,7 @@ export function useProjectsState({
setSelectedProject(project); setSelectedProject(project);
setSelectedSession(null); setSelectedSession(null);
setActiveTab('chat'); setActiveTab('chat');
setNewSessionTrigger((previous) => previous + 1);
navigate('/'); navigate('/');
if (isMobile) { if (isMobile) {
@@ -806,6 +864,7 @@ export function useProjectsState({
showSettings, showSettings,
settingsInitialTab, settingsInitialTab,
externalMessageUpdate, externalMessageUpdate,
newSessionTrigger,
setActiveTab, setActiveTab,
setSidebarOpen, setSidebarOpen,
setIsInputFocused, setIsInputFocused,

View File

@@ -89,12 +89,14 @@
"permissionMode": "Berechtigungsmodus", "permissionMode": "Berechtigungsmodus",
"modes": { "modes": {
"default": "Standardmodus", "default": "Standardmodus",
"auto": "Auto Mode",
"acceptEdits": "Bearbeitungen akzeptieren", "acceptEdits": "Bearbeitungen akzeptieren",
"bypassPermissions": "Berechtigungen umgehen", "bypassPermissions": "Berechtigungen umgehen",
"plan": "Planungsmodus" "plan": "Planungsmodus"
}, },
"descriptions": { "descriptions": {
"default": "Nur vertrauenswürdige Befehle (ls, cat, grep, git status usw.) werden automatisch ausgeführt. Andere Befehle werden übersprungen. Kann in den Arbeitsbereich schreiben.", "default": "Nur vertrauenswürdige Befehle (ls, cat, grep, git status usw.) werden automatisch ausgeführt. Andere Befehle werden übersprungen. Kann in den Arbeitsbereich schreiben.",
"auto": "A model classifier decides per tool call whether to approve or deny. Hands-off, but safer than Bypass — denials still happen.",
"acceptEdits": "Alle Befehle werden automatisch innerhalb des Arbeitsbereichs ausgeführt. Vollautomatischer Modus mit isolierter Ausführung.", "acceptEdits": "Alle Befehle werden automatisch innerhalb des Arbeitsbereichs ausgeführt. Vollautomatischer Modus mit isolierter Ausführung.",
"bypassPermissions": "Vollständiger Systemzugriff ohne Einschränkungen. Alle Befehle werden automatisch mit vollem Festplatten- und Netzwerkzugriff ausgeführt. Mit Vorsicht verwenden.", "bypassPermissions": "Vollständiger Systemzugriff ohne Einschränkungen. Alle Befehle werden automatisch mit vollem Festplatten- und Netzwerkzugriff ausgeführt. Mit Vorsicht verwenden.",
"plan": "Planungsmodus keine Befehle werden ausgeführt" "plan": "Planungsmodus keine Befehle werden ausgeführt"

View File

@@ -89,12 +89,14 @@
"permissionMode": "Permission Mode", "permissionMode": "Permission Mode",
"modes": { "modes": {
"default": "Default Mode", "default": "Default Mode",
"auto": "Auto Mode",
"acceptEdits": "Accept Edits", "acceptEdits": "Accept Edits",
"bypassPermissions": "Bypass Permissions", "bypassPermissions": "Bypass Permissions",
"plan": "Plan Mode" "plan": "Plan Mode"
}, },
"descriptions": { "descriptions": {
"default": "Only trusted commands (ls, cat, grep, git status, etc.) run automatically. Other commands are skipped. Can write to workspace.", "default": "Only trusted commands (ls, cat, grep, git status, etc.) run automatically. Other commands are skipped. Can write to workspace.",
"auto": "A model classifier decides per tool call whether to approve or deny. Hands-off, but safer than Bypass — denials still happen.",
"acceptEdits": "All commands run automatically within the workspace. Full auto mode with sandboxed execution.", "acceptEdits": "All commands run automatically within the workspace. Full auto mode with sandboxed execution.",
"bypassPermissions": "Full system access with no restrictions. All commands run automatically with full disk and network access. Use with caution.", "bypassPermissions": "Full system access with no restrictions. All commands run automatically with full disk and network access. Use with caution.",
"plan": "Planning mode - no commands are executed" "plan": "Planning mode - no commands are executed"

View File

@@ -89,12 +89,14 @@
"permissionMode": "Modalità permessi", "permissionMode": "Modalità permessi",
"modes": { "modes": {
"default": "Modalità predefinita", "default": "Modalità predefinita",
"auto": "Auto Mode",
"acceptEdits": "Accetta modifiche", "acceptEdits": "Accetta modifiche",
"bypassPermissions": "Ignora permessi", "bypassPermissions": "Ignora permessi",
"plan": "Modalità piano" "plan": "Modalità piano"
}, },
"descriptions": { "descriptions": {
"default": "Solo i comandi attendibili (ls, cat, grep, git status, ecc.) vengono eseguiti automaticamente. Gli altri comandi vengono saltati. Può scrivere nell'area di lavoro.", "default": "Solo i comandi attendibili (ls, cat, grep, git status, ecc.) vengono eseguiti automaticamente. Gli altri comandi vengono saltati. Può scrivere nell'area di lavoro.",
"auto": "A model classifier decides per tool call whether to approve or deny. Hands-off, but safer than Bypass — denials still happen.",
"acceptEdits": "Tutti i comandi vengono eseguiti automaticamente nell'area di lavoro. Modalità completamente automatica con esecuzione sandboxed.", "acceptEdits": "Tutti i comandi vengono eseguiti automaticamente nell'area di lavoro. Modalità completamente automatica con esecuzione sandboxed.",
"bypassPermissions": "Accesso completo al sistema senza restrizioni. Tutti i comandi vengono eseguiti automaticamente con accesso completo a disco e rete. Usa con cautela.", "bypassPermissions": "Accesso completo al sistema senza restrizioni. Tutti i comandi vengono eseguiti automaticamente con accesso completo a disco e rete. Usa con cautela.",
"plan": "Modalità pianificazione - nessun comando viene eseguito" "plan": "Modalità pianificazione - nessun comando viene eseguito"

View File

@@ -88,12 +88,14 @@
"permissionMode": "権限モード", "permissionMode": "権限モード",
"modes": { "modes": {
"default": "デフォルトモード", "default": "デフォルトモード",
"auto": "Auto Mode",
"acceptEdits": "編集を許可", "acceptEdits": "編集を許可",
"bypassPermissions": "権限をバイパス", "bypassPermissions": "権限をバイパス",
"plan": "プランモード" "plan": "プランモード"
}, },
"descriptions": { "descriptions": {
"default": "信頼されたコマンドls、cat、grep、git statusなどのみ自動実行。その他のコマンドはスキップ。ワークスペースへの書き込みは可能。", "default": "信頼されたコマンドls、cat、grep、git statusなどのみ自動実行。その他のコマンドはスキップ。ワークスペースへの書き込みは可能。",
"auto": "A model classifier decides per tool call whether to approve or deny. Hands-off, but safer than Bypass — denials still happen.",
"acceptEdits": "ワークスペース内ですべてのコマンドを自動実行。サンドボックス環境での完全自動モード。", "acceptEdits": "ワークスペース内ですべてのコマンドを自動実行。サンドボックス環境での完全自動モード。",
"bypassPermissions": "制限なしの完全なシステムアクセス。すべてのコマンドがディスクとネットワークへの完全なアクセスで自動実行されます。注意して使用してください。", "bypassPermissions": "制限なしの完全なシステムアクセス。すべてのコマンドがディスクとネットワークへの完全なアクセスで自動実行されます。注意して使用してください。",
"plan": "プランニングモード - コマンドは実行されません" "plan": "プランニングモード - コマンドは実行されません"

View File

@@ -89,12 +89,14 @@
"permissionMode": "권한 모드", "permissionMode": "권한 모드",
"modes": { "modes": {
"default": "기본 모드", "default": "기본 모드",
"auto": "Auto Mode",
"acceptEdits": "편집 허용", "acceptEdits": "편집 허용",
"bypassPermissions": "권한 우회", "bypassPermissions": "권한 우회",
"plan": "Plan 모드" "plan": "Plan 모드"
}, },
"descriptions": { "descriptions": {
"default": "신뢰할 수 있는 명령어(ls, cat, grep, git status 등)만 자동 실행됩니다. 다른 명령어는 건너뜁니다. 워크스페이스에 쓰기 가능.", "default": "신뢰할 수 있는 명령어(ls, cat, grep, git status 등)만 자동 실행됩니다. 다른 명령어는 건너뜁니다. 워크스페이스에 쓰기 가능.",
"auto": "A model classifier decides per tool call whether to approve or deny. Hands-off, but safer than Bypass — denials still happen.",
"acceptEdits": "워크스페이스 내에서 모든 명령어가 자동 실행됩니다. 샌드박스 내 완전 자동 모드.", "acceptEdits": "워크스페이스 내에서 모든 명령어가 자동 실행됩니다. 샌드박스 내 완전 자동 모드.",
"bypassPermissions": "제한 없는 전체 시스템 접근. 모든 명령어가 전체 디스크 및 네트워크 접근 권한으로 자동 실행됩니다. 주의해서 사용하세요.", "bypassPermissions": "제한 없는 전체 시스템 접근. 모든 명령어가 전체 디스크 및 네트워크 접근 권한으로 자동 실행됩니다. 주의해서 사용하세요.",
"plan": "계획 모드 - 명령어가 실행되지 않습니다" "plan": "계획 모드 - 명령어가 실행되지 않습니다"

View File

@@ -89,12 +89,14 @@
"permissionMode": "Режим разрешений", "permissionMode": "Режим разрешений",
"modes": { "modes": {
"default": "Режим по умолчанию", "default": "Режим по умолчанию",
"auto": "Auto Mode",
"acceptEdits": "Принимать правки", "acceptEdits": "Принимать правки",
"bypassPermissions": "Обход разрешений", "bypassPermissions": "Обход разрешений",
"plan": "Режим планирования" "plan": "Режим планирования"
}, },
"descriptions": { "descriptions": {
"default": "Только доверенные команды (ls, cat, grep, git status и т.д.) выполняются автоматически. Другие команды пропускаются. Может записывать в рабочее пространство.", "default": "Только доверенные команды (ls, cat, grep, git status и т.д.) выполняются автоматически. Другие команды пропускаются. Может записывать в рабочее пространство.",
"auto": "A model classifier decides per tool call whether to approve or deny. Hands-off, but safer than Bypass — denials still happen.",
"acceptEdits": "Все команды выполняются автоматически в рабочем пространстве. Полный автоматический режим с изолированным выполнением.", "acceptEdits": "Все команды выполняются автоматически в рабочем пространстве. Полный автоматический режим с изолированным выполнением.",
"bypassPermissions": "Полный системный доступ без ограничений. Все команды выполняются автоматически с полным доступом к диску и сети. Используйте с осторожностью.", "bypassPermissions": "Полный системный доступ без ограничений. Все команды выполняются автоматически с полным доступом к диску и сети. Используйте с осторожностью.",
"plan": "Режим планирования - команды не выполняются" "plan": "Режим планирования - команды не выполняются"

View File

@@ -89,12 +89,14 @@
"permissionMode": "İzin Modu", "permissionMode": "İzin Modu",
"modes": { "modes": {
"default": "Varsayılan Mod", "default": "Varsayılan Mod",
"auto": "Auto Mode",
"acceptEdits": "Düzenlemeleri Kabul Et", "acceptEdits": "Düzenlemeleri Kabul Et",
"bypassPermissions": "İzinleri Atla", "bypassPermissions": "İzinleri Atla",
"plan": "Plan Modu" "plan": "Plan Modu"
}, },
"descriptions": { "descriptions": {
"default": "Sadece güvenilir komutlar (ls, cat, grep, git status, vb.) otomatik çalışır. Diğer komutlar atlanır. Çalışma alanına yazabilir.", "default": "Sadece güvenilir komutlar (ls, cat, grep, git status, vb.) otomatik çalışır. Diğer komutlar atlanır. Çalışma alanına yazabilir.",
"auto": "A model classifier decides per tool call whether to approve or deny. Hands-off, but safer than Bypass — denials still happen.",
"acceptEdits": "Tüm komutlar çalışma alanı içinde otomatik çalışır. Sandbox'lu çalıştırma ile tam otomatik mod.", "acceptEdits": "Tüm komutlar çalışma alanı içinde otomatik çalışır. Sandbox'lu çalıştırma ile tam otomatik mod.",
"bypassPermissions": "Kısıtlama olmadan tam sistem erişimi. Tüm komutlar tam disk ve ağ erişimiyle otomatik çalışır. Dikkatli kullan.", "bypassPermissions": "Kısıtlama olmadan tam sistem erişimi. Tüm komutlar tam disk ve ağ erişimiyle otomatik çalışır. Dikkatli kullan.",
"plan": "Planlama modu — hiçbir komut çalıştırılmaz" "plan": "Planlama modu — hiçbir komut çalıştırılmaz"

View File

@@ -89,12 +89,14 @@
"permissionMode": "权限模式", "permissionMode": "权限模式",
"modes": { "modes": {
"default": "默认模式", "default": "默认模式",
"auto": "Auto Mode",
"acceptEdits": "编辑模式", "acceptEdits": "编辑模式",
"bypassPermissions": "无限制模式", "bypassPermissions": "无限制模式",
"plan": "计划模式" "plan": "计划模式"
}, },
"descriptions": { "descriptions": {
"default": "只有受信任的命令ls、cat、grep、git status 等)自动运行。其他命令将被跳过。可以写入工作区。", "default": "只有受信任的命令ls、cat、grep、git status 等)自动运行。其他命令将被跳过。可以写入工作区。",
"auto": "A model classifier decides per tool call whether to approve or deny. Hands-off, but safer than Bypass — denials still happen.",
"acceptEdits": "工作区内的所有命令自动运行。完全自动模式,具有沙盒执行功能。", "acceptEdits": "工作区内的所有命令自动运行。完全自动模式,具有沙盒执行功能。",
"bypassPermissions": "完全的系统访问,无限制。所有命令自动运行,具有完整的磁盘和网络访问权限。请谨慎使用。", "bypassPermissions": "完全的系统访问,无限制。所有命令自动运行,具有完整的磁盘和网络访问权限。请谨慎使用。",
"plan": "计划模式 - 不执行任何命令" "plan": "计划模式 - 不执行任何命令"

View File

@@ -104,17 +104,126 @@ function createEmptySlot(): SessionSlot {
} }
/** /**
* Compute merged messages: server + realtime, deduped by id. * Compute merged messages: server + realtime, deduped by id and adjacent
* Server messages take priority (they're the persisted source of truth). * assistant echo (same trimmed text), so finalized stream rows do not stack
* Realtime messages that aren't yet in server stay (in-flight streaming). * on top of the persisted copy before realtime is cleared.
*/ */
function userTextFingerprint(m: NormalizedMessage): string | null {
if (m.kind !== 'text' || m.role !== 'user') return null;
const t = (m.content || '').trim();
return t.length > 0 ? t : null;
}
/**
* After `finalizeStreaming`, the client holds a synthetic assistant `text` row
* while the sessions API soon returns the same reply with a different id.
* Those sit back-to-back in merged order and look like duplicate bubbles until
* `refreshFromServer` clears realtime. Collapse same-text assistant rows and
* stream_placeholder → text when content matches.
*/
function dedupeAdjacentAssistantEchoes(merged: NormalizedMessage[]): NormalizedMessage[] {
const out: NormalizedMessage[] = [];
for (const m of merged) {
const prev = out[out.length - 1];
if (prev) {
if (prev.kind === 'stream_delta' && m.kind === 'text' && m.role === 'assistant') {
const ps = (prev.content || '').trim();
const ms = (m.content || '').trim();
if (ps.length > 0 && ps === ms) {
out[out.length - 1] = m;
continue;
}
}
if (
prev.kind === 'text'
&& m.kind === 'text'
&& prev.role === 'assistant'
&& m.role === 'assistant'
) {
const ms = (m.content || '').trim();
if (ms.length > 0 && ms === (prev.content || '').trim()) {
continue;
}
}
}
out.push(m);
}
return out;
}
function computeMerged(server: NormalizedMessage[], realtime: NormalizedMessage[]): NormalizedMessage[] { function computeMerged(server: NormalizedMessage[], realtime: NormalizedMessage[]): NormalizedMessage[] {
if (realtime.length === 0) return server; if (realtime.length === 0) return server;
if (server.length === 0) return realtime; if (server.length === 0) return dedupeAdjacentAssistantEchoes(realtime);
const serverIds = new Set(server.map(m => m.id)); const serverIds = new Set(server.map(m => m.id));
const extra = realtime.filter(m => !serverIds.has(m.id)); const serverUserTexts = new Set(
server.map(userTextFingerprint).filter((t): t is string => t !== null),
);
const extra = realtime.filter((m) => {
if (serverIds.has(m.id)) return false;
// Optimistic user rows use `local_*` ids; once the same text exists on the
// server-backed copy, drop the realtime echo to avoid duplicate bubbles.
if (m.id.startsWith('local_')) {
const fp = userTextFingerprint(m);
if (fp && serverUserTexts.has(fp)) return false;
}
return true;
});
if (extra.length === 0) return server; if (extra.length === 0) return server;
return [...server, ...extra]; return dedupeAdjacentAssistantEchoes([...server, ...extra]);
}
function compareMessagesByTimestamp(left: NormalizedMessage, right: NormalizedMessage): number {
const leftTime = Date.parse(left.timestamp);
const rightTime = Date.parse(right.timestamp);
if (Number.isNaN(leftTime) || Number.isNaN(rightTime) || leftTime === rightTime) {
return 0;
}
return leftTime - rightTime;
}
function rewriteMessageSessionId(
msg: NormalizedMessage,
fromSessionId: string,
toSessionId: string,
): NormalizedMessage {
const streamingSourceId = `__streaming_${fromSessionId}`;
const nextId = msg.id === streamingSourceId ? `__streaming_${toSessionId}` : msg.id;
if (msg.sessionId === toSessionId && nextId === msg.id) {
return msg;
}
return {
...msg,
id: nextId,
sessionId: toSessionId,
};
}
function mergeMessagesById(
existing: NormalizedMessage[],
incoming: NormalizedMessage[],
): NormalizedMessage[] {
if (existing.length === 0) return incoming;
if (incoming.length === 0) return existing;
const merged = [...existing, ...incoming];
const deduped: NormalizedMessage[] = [];
const seen = new Set<string>();
for (const msg of merged) {
if (seen.has(msg.id)) {
continue;
}
seen.add(msg.id);
deduped.push(msg);
}
deduped.sort(compareMessagesByTimestamp);
return deduped;
} }
/** /**
@@ -141,28 +250,59 @@ const MAX_REALTIME_MESSAGES = 500;
export function useSessionStore() { export function useSessionStore() {
const storeRef = useRef(new Map<string, SessionSlot>()); const storeRef = useRef(new Map<string, SessionSlot>());
const sessionAliasesRef = useRef(new Map<string, string>());
const activeSessionIdRef = useRef<string | null>(null); const activeSessionIdRef = useRef<string | null>(null);
// Bump to force re-render — only when the active session's data changes // Bump to force re-render — only when the active session's data changes
const [, setTick] = useState(0); const [, setTick] = useState(0);
const notify = useCallback((sessionId: string) => { const notify = useCallback((sessionId: string) => {
if (sessionId === activeSessionIdRef.current) { const aliases = sessionAliasesRef.current;
let resolvedSessionId = sessionId;
const visited = new Set<string>();
while (aliases.has(resolvedSessionId) && !visited.has(resolvedSessionId)) {
visited.add(resolvedSessionId);
resolvedSessionId = aliases.get(resolvedSessionId)!;
}
if (resolvedSessionId === activeSessionIdRef.current) {
setTick(n => n + 1); setTick(n => n + 1);
} }
}, []); }, []);
const setActiveSession = useCallback((sessionId: string | null) => { const resolveSessionId = useCallback((sessionId: string | null | undefined): string | null => {
activeSessionIdRef.current = sessionId; if (!sessionId) {
return null;
}
const aliases = sessionAliasesRef.current;
let resolvedSessionId = sessionId;
const visited = new Set<string>();
while (aliases.has(resolvedSessionId) && !visited.has(resolvedSessionId)) {
visited.add(resolvedSessionId);
resolvedSessionId = aliases.get(resolvedSessionId)!;
}
return resolvedSessionId;
}, []); }, []);
const setActiveSession = useCallback((sessionId: string | null) => {
activeSessionIdRef.current = resolveSessionId(sessionId);
}, [resolveSessionId]);
const getSlot = useCallback((sessionId: string): SessionSlot => { const getSlot = useCallback((sessionId: string): SessionSlot => {
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const store = storeRef.current; const store = storeRef.current;
if (!store.has(sessionId)) { if (!store.has(resolvedSessionId)) {
store.set(sessionId, createEmptySlot()); store.set(resolvedSessionId, createEmptySlot());
} }
return store.get(sessionId)!; return store.get(resolvedSessionId)!;
}, []); }, [resolveSessionId]);
const has = useCallback((sessionId: string) => storeRef.current.has(sessionId), []); const has = useCallback((sessionId: string) => {
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
return storeRef.current.has(resolvedSessionId);
}, [resolveSessionId]);
/** /**
* Fetch messages from the provider sessions endpoint and populate serverMessages. * Fetch messages from the provider sessions endpoint and populate serverMessages.
@@ -179,9 +319,10 @@ export function useSessionStore() {
offset?: number; offset?: number;
} = {}, } = {},
) => { ) => {
const slot = getSlot(sessionId); const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = getSlot(resolvedSessionId);
slot.status = 'loading'; slot.status = 'loading';
notify(sessionId); notify(resolvedSessionId);
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -191,7 +332,7 @@ export function useSessionStore() {
} }
const qs = params.toString(); const qs = params.toString();
const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`; const url = `/api/providers/sessions/${encodeURIComponent(resolvedSessionId)}/messages${qs ? `?${qs}` : ''}`;
const response = await authenticatedFetch(url); const response = await authenticatedFetch(url);
if (!response.ok) { if (!response.ok) {
@@ -212,15 +353,15 @@ export function useSessionStore() {
slot.tokenUsage = data.tokenUsage; slot.tokenUsage = data.tokenUsage;
} }
notify(sessionId); notify(resolvedSessionId);
return slot; return slot;
} catch (error) { } catch (error) {
console.error(`[SessionStore] fetch failed for ${sessionId}:`, error); console.error(`[SessionStore] fetch failed for ${resolvedSessionId}:`, error);
slot.status = 'error'; slot.status = 'error';
notify(sessionId); notify(resolvedSessionId);
return slot; return slot;
} }
}, [getSlot, notify]); }, [getSlot, notify, resolveSessionId]);
/** /**
* Load older (paginated) messages and prepend to serverMessages. * Load older (paginated) messages and prepend to serverMessages.
@@ -234,7 +375,8 @@ export function useSessionStore() {
limit?: number; limit?: number;
} = {}, } = {},
) => { ) => {
const slot = getSlot(sessionId); const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = getSlot(resolvedSessionId);
if (!slot.hasMore) return slot; if (!slot.hasMore) return slot;
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -243,7 +385,7 @@ export function useSessionStore() {
params.append('offset', String(slot.offset)); params.append('offset', String(slot.offset));
const qs = params.toString(); const qs = params.toString();
const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`; const url = `/api/providers/sessions/${encodeURIComponent(resolvedSessionId)}/messages${qs ? `?${qs}` : ''}`;
try { try {
const response = await authenticatedFetch(url); const response = await authenticatedFetch(url);
@@ -256,43 +398,54 @@ export function useSessionStore() {
slot.hasMore = Boolean(data.hasMore); slot.hasMore = Boolean(data.hasMore);
slot.offset = slot.offset + olderMessages.length; slot.offset = slot.offset + olderMessages.length;
recomputeMergedIfNeeded(slot); recomputeMergedIfNeeded(slot);
notify(sessionId); notify(resolvedSessionId);
return slot; return slot;
} catch (error) { } catch (error) {
console.error(`[SessionStore] fetchMore failed for ${sessionId}:`, error); console.error(`[SessionStore] fetchMore failed for ${resolvedSessionId}:`, error);
return slot; return slot;
} }
}, [getSlot, notify]); }, [getSlot, notify, resolveSessionId]);
/** /**
* Append a realtime (WebSocket) message to the correct session slot. * Append a realtime (WebSocket) message to the correct session slot.
* This works regardless of which session is actively viewed. * This works regardless of which session is actively viewed.
*/ */
const appendRealtime = useCallback((sessionId: string, msg: NormalizedMessage) => { const appendRealtime = useCallback((sessionId: string, msg: NormalizedMessage) => {
const slot = getSlot(sessionId); const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
let updated = [...slot.realtimeMessages, msg]; const slot = getSlot(resolvedSessionId);
const normalizedMessage =
msg.sessionId === resolvedSessionId
? msg
: { ...msg, sessionId: resolvedSessionId };
let updated = [...slot.realtimeMessages, normalizedMessage];
if (updated.length > MAX_REALTIME_MESSAGES) { if (updated.length > MAX_REALTIME_MESSAGES) {
updated = updated.slice(-MAX_REALTIME_MESSAGES); updated = updated.slice(-MAX_REALTIME_MESSAGES);
} }
slot.realtimeMessages = updated; slot.realtimeMessages = updated;
recomputeMergedIfNeeded(slot); recomputeMergedIfNeeded(slot);
notify(sessionId); notify(resolvedSessionId);
}, [getSlot, notify]); }, [getSlot, notify, resolveSessionId]);
/** /**
* Append multiple realtime messages at once (batch). * Append multiple realtime messages at once (batch).
*/ */
const appendRealtimeBatch = useCallback((sessionId: string, msgs: NormalizedMessage[]) => { const appendRealtimeBatch = useCallback((sessionId: string, msgs: NormalizedMessage[]) => {
if (msgs.length === 0) return; if (msgs.length === 0) return;
const slot = getSlot(sessionId); const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
let updated = [...slot.realtimeMessages, ...msgs]; const slot = getSlot(resolvedSessionId);
const normalizedMessages = msgs.map((msg) =>
msg.sessionId === resolvedSessionId
? msg
: { ...msg, sessionId: resolvedSessionId },
);
let updated = [...slot.realtimeMessages, ...normalizedMessages];
if (updated.length > MAX_REALTIME_MESSAGES) { if (updated.length > MAX_REALTIME_MESSAGES) {
updated = updated.slice(-MAX_REALTIME_MESSAGES); updated = updated.slice(-MAX_REALTIME_MESSAGES);
} }
slot.realtimeMessages = updated; slot.realtimeMessages = updated;
recomputeMergedIfNeeded(slot); recomputeMergedIfNeeded(slot);
notify(sessionId); notify(resolvedSessionId);
}, [getSlot, notify]); }, [getSlot, notify, resolveSessionId]);
/** /**
* Re-fetch serverMessages from the provider sessions endpoint. * Re-fetch serverMessages from the provider sessions endpoint.
@@ -305,12 +458,13 @@ export function useSessionStore() {
projectPath?: string; projectPath?: string;
} = {}, } = {},
) => { ) => {
const slot = getSlot(sessionId); const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = getSlot(resolvedSessionId);
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
const qs = params.toString(); const qs = params.toString();
const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`; const url = `/api/providers/sessions/${encodeURIComponent(resolvedSessionId)}/messages${qs ? `?${qs}` : ''}`;
const response = await authenticatedFetch(url); const response = await authenticatedFetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -323,40 +477,43 @@ export function useSessionStore() {
// drop realtime messages that the server has caught up with to prevent unbounded growth. // drop realtime messages that the server has caught up with to prevent unbounded growth.
slot.realtimeMessages = []; slot.realtimeMessages = [];
recomputeMergedIfNeeded(slot); recomputeMergedIfNeeded(slot);
notify(sessionId); notify(resolvedSessionId);
} catch (error) { } catch (error) {
console.error(`[SessionStore] refresh failed for ${sessionId}:`, error); console.error(`[SessionStore] refresh failed for ${resolvedSessionId}:`, error);
} }
}, [getSlot, notify]); }, [getSlot, notify, resolveSessionId]);
/** /**
* Update session status. * Update session status.
*/ */
const setStatus = useCallback((sessionId: string, status: SessionStatus) => { const setStatus = useCallback((sessionId: string, status: SessionStatus) => {
const slot = getSlot(sessionId); const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = getSlot(resolvedSessionId);
slot.status = status; slot.status = status;
notify(sessionId); notify(resolvedSessionId);
}, [getSlot, notify]); }, [getSlot, notify, resolveSessionId]);
/** /**
* Check if a session's data is stale (>30s old). * Check if a session's data is stale (>30s old).
*/ */
const isStale = useCallback((sessionId: string) => { const isStale = useCallback((sessionId: string) => {
const slot = storeRef.current.get(sessionId); const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = storeRef.current.get(resolvedSessionId);
if (!slot) return true; if (!slot) return true;
return Date.now() - slot.fetchedAt > STALE_THRESHOLD_MS; return Date.now() - slot.fetchedAt > STALE_THRESHOLD_MS;
}, []); }, [resolveSessionId]);
/** /**
* Update or create a streaming message (accumulated text so far). * Update or create a streaming message (accumulated text so far).
* Uses a well-known ID so subsequent calls replace the same message. * Uses a well-known ID so subsequent calls replace the same message.
*/ */
const updateStreaming = useCallback((sessionId: string, accumulatedText: string, msgProvider: LLMProvider) => { const updateStreaming = useCallback((sessionId: string, accumulatedText: string, msgProvider: LLMProvider) => {
const slot = getSlot(sessionId); const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const streamId = `__streaming_${sessionId}`; const slot = getSlot(resolvedSessionId);
const streamId = `__streaming_${resolvedSessionId}`;
const msg: NormalizedMessage = { const msg: NormalizedMessage = {
id: streamId, id: streamId,
sessionId, sessionId: resolvedSessionId,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
provider: msgProvider, provider: msgProvider,
kind: 'stream_delta', kind: 'stream_delta',
@@ -370,17 +527,18 @@ export function useSessionStore() {
slot.realtimeMessages = [...slot.realtimeMessages, msg]; slot.realtimeMessages = [...slot.realtimeMessages, msg];
} }
recomputeMergedIfNeeded(slot); recomputeMergedIfNeeded(slot);
notify(sessionId); notify(resolvedSessionId);
}, [getSlot, notify]); }, [getSlot, notify, resolveSessionId]);
/** /**
* Finalize streaming: convert the streaming message to a regular text message. * Finalize streaming: convert the streaming message to a regular text message.
* The well-known streaming ID is replaced with a unique text message ID. * The well-known streaming ID is replaced with a unique text message ID.
*/ */
const finalizeStreaming = useCallback((sessionId: string) => { const finalizeStreaming = useCallback((sessionId: string) => {
const slot = storeRef.current.get(sessionId); const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = storeRef.current.get(resolvedSessionId);
if (!slot) return; if (!slot) return;
const streamId = `__streaming_${sessionId}`; const streamId = `__streaming_${resolvedSessionId}`;
const idx = slot.realtimeMessages.findIndex(m => m.id === streamId); const idx = slot.realtimeMessages.findIndex(m => m.id === streamId);
if (idx >= 0) { if (idx >= 0) {
const stream = slot.realtimeMessages[idx]; const stream = slot.realtimeMessages[idx];
@@ -392,35 +550,104 @@ export function useSessionStore() {
role: 'assistant', role: 'assistant',
}; };
recomputeMergedIfNeeded(slot); recomputeMergedIfNeeded(slot);
notify(sessionId); notify(resolvedSessionId);
} }
}, [notify]); }, [notify, resolveSessionId]);
/** /**
* Clear realtime messages for a session (e.g., after stream completes and server fetch catches up). * Clear realtime messages for a session (e.g., after stream completes and server fetch catches up).
*/ */
const clearRealtime = useCallback((sessionId: string) => { const clearRealtime = useCallback((sessionId: string) => {
const slot = storeRef.current.get(sessionId); const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = storeRef.current.get(resolvedSessionId);
if (slot) { if (slot) {
slot.realtimeMessages = []; slot.realtimeMessages = [];
recomputeMergedIfNeeded(slot); recomputeMergedIfNeeded(slot);
notify(sessionId); notify(resolvedSessionId);
} }
}, [notify]); }, [notify, resolveSessionId]);
/** /**
* Get merged messages for a session (for rendering). * Get merged messages for a session (for rendering).
*/ */
const getMessages = useCallback((sessionId: string): NormalizedMessage[] => { const getMessages = useCallback((sessionId: string): NormalizedMessage[] => {
return storeRef.current.get(sessionId)?.merged ?? []; const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
}, []); return storeRef.current.get(resolvedSessionId)?.merged ?? [];
}, [resolveSessionId]);
/** /**
* Get session slot (for status, pagination info, etc.). * Get session slot (for status, pagination info, etc.).
*/ */
const getSessionSlot = useCallback((sessionId: string): SessionSlot | undefined => { const getSessionSlot = useCallback((sessionId: string): SessionSlot | undefined => {
return storeRef.current.get(sessionId); const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
}, []); return storeRef.current.get(resolvedSessionId);
}, [resolveSessionId]);
const replaceSessionId = useCallback((fromSessionId: string, toSessionId: string) => {
const resolvedFromSessionId = resolveSessionId(fromSessionId) ?? fromSessionId;
const resolvedToSessionId = resolveSessionId(toSessionId) ?? toSessionId;
if (resolvedFromSessionId === resolvedToSessionId) {
sessionAliasesRef.current.set(fromSessionId, resolvedToSessionId);
return;
}
const store = storeRef.current;
const sourceSlot = store.get(resolvedFromSessionId);
const targetSlot = store.get(resolvedToSessionId) ?? createEmptySlot();
if (sourceSlot) {
const migratedServerMessages = sourceSlot.serverMessages.map((msg) =>
rewriteMessageSessionId(msg, resolvedFromSessionId, resolvedToSessionId),
);
const migratedRealtimeMessages = sourceSlot.realtimeMessages.map((msg) =>
rewriteMessageSessionId(msg, resolvedFromSessionId, resolvedToSessionId),
);
targetSlot.serverMessages = mergeMessagesById(targetSlot.serverMessages, migratedServerMessages);
targetSlot.realtimeMessages = mergeMessagesById(targetSlot.realtimeMessages, migratedRealtimeMessages);
if (targetSlot.realtimeMessages.length > MAX_REALTIME_MESSAGES) {
targetSlot.realtimeMessages = targetSlot.realtimeMessages.slice(-MAX_REALTIME_MESSAGES);
}
targetSlot.status =
sourceSlot.status === 'error'
? 'error'
: sourceSlot.status === 'streaming' || targetSlot.status === 'streaming'
? 'streaming'
: sourceSlot.status === 'loading' || targetSlot.status === 'loading'
? 'loading'
: targetSlot.status;
targetSlot.fetchedAt = Math.max(targetSlot.fetchedAt, sourceSlot.fetchedAt, Date.now());
targetSlot.total = Math.max(
targetSlot.total,
sourceSlot.total,
targetSlot.serverMessages.length,
targetSlot.realtimeMessages.length,
);
targetSlot.hasMore = targetSlot.hasMore || sourceSlot.hasMore;
targetSlot.offset = Math.max(targetSlot.offset, sourceSlot.offset);
targetSlot.tokenUsage = targetSlot.tokenUsage ?? sourceSlot.tokenUsage;
recomputeMergedIfNeeded(targetSlot);
store.set(resolvedToSessionId, targetSlot);
store.delete(resolvedFromSessionId);
}
sessionAliasesRef.current.set(resolvedFromSessionId, resolvedToSessionId);
sessionAliasesRef.current.set(fromSessionId, resolvedToSessionId);
for (const [aliasSessionId, targetSessionId] of sessionAliasesRef.current.entries()) {
if (targetSessionId === resolvedFromSessionId) {
sessionAliasesRef.current.set(aliasSessionId, resolvedToSessionId);
}
}
if (activeSessionIdRef.current === resolvedFromSessionId) {
activeSessionIdRef.current = resolvedToSessionId;
}
notify(resolvedToSessionId);
}, [notify, resolveSessionId]);
return useMemo(() => ({ return useMemo(() => ({
getSlot, getSlot,
@@ -438,11 +665,12 @@ export function useSessionStore() {
clearRealtime, clearRealtime,
getMessages, getMessages,
getSessionSlot, getSessionSlot,
replaceSessionId,
}), [ }), [
getSlot, has, fetchFromServer, fetchMore, getSlot, has, fetchFromServer, fetchMore,
appendRealtime, appendRealtimeBatch, refreshFromServer, appendRealtime, appendRealtimeBatch, refreshFromServer,
setActiveSession, setStatus, isStale, updateStreaming, finalizeStreaming, setActiveSession, setStatus, isStale, updateStreaming, finalizeStreaming,
clearRealtime, getMessages, getSessionSlot, clearRealtime, getMessages, getSessionSlot, replaceSessionId,
]); ]);
} }