mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-04 19:58:48 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beb0a50413 | ||
|
|
e89d2da5df | ||
|
|
392c73b693 | ||
|
|
5e7c4c5f8c | ||
|
|
3f71d4932b |
@@ -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
|
||||||
|
|||||||
@@ -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
4
package-lock.json
generated
@@ -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": {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
61
server/shared/claude-cli-path.test.ts
Normal file
61
server/shared/claude-cli-path.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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 can parse a wrapper file path containing letters r and n before claude.exe', () => {
|
||||||
|
const wrapperPath = 'C:\\tools\\claude';
|
||||||
|
const nativePath = 'C:\\tools\\custom\\bin\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe';
|
||||||
|
const readFileSync = (() => `exec "$basedir/custom/bin/node_modules/@anthropic-ai/claude-code/bin/claude.exe" "$@"`) as unknown as ResolveClaudeCodeExecutablePathDependencies['readFileSync'];
|
||||||
|
|
||||||
|
const resolved = resolveClaudeCodeExecutablePath(wrapperPath, {
|
||||||
|
platform: 'win32',
|
||||||
|
existsSync: (candidate) => candidate === nativePath,
|
||||||
|
readFileSync,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resolved, nativePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
139
server/shared/claude-cli-path.ts
Normal file
139
server/shared/claude-cli-path.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) : [];
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": "プランニングモード - コマンドは実行されません"
|
||||||
|
|||||||
@@ -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": "계획 모드 - 명령어가 실행되지 않습니다"
|
||||||
|
|||||||
@@ -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": "Режим планирования - команды не выполняются"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": "计划模式 - 不执行任何命令"
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user