mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-06 12:45:45 +00:00
Fix New session issues and websocket issues (#738)
* fix: reset-state-on-new-session-click * fix(chat): preserve continuity while session ids settle New conversations were crossing a short but important consistency gap. The route could already point at a newly created session id while the projects payload had not refreshed yet, and realtime/optimistic messages could still be keyed under a provisional id. In that window the UI could stop reading the active session store, briefly render the conversation as missing, and then repopulate it a moment later. That same gap also made duplication more likely. Optimistic local user messages could survive long enough to appear beside the persisted copy, and finalized assistant streaming rows could sit directly next to the server-backed assistant message with the same content before realtime state was cleared. The result was a chat view that felt unstable exactly when a new session was being created. This commit makes session-id reconciliation a first-class part of the chat flow instead of assuming every layer will agree immediately. The session store now understands canonical session aliases and can migrate one conversation from a provisional id to the real id without dropping its in-memory state. The route navigation path can replace the provisional URL entry instead of stacking it in history, and the project/session selection logic keeps a synthetic selected session alive long enough for the sidebar and project payloads to catch up. The practical goal is to keep one visible conversation throughout the whole creation lifecycle: no dead window between websocket events and project refresh, no stale provisional URL after the real id is known, and no extra optimistic/local bubbles when server history catches up. * fix(cli): resolve executable path for Claude CLI on Windows * fix(session-synchronizer): improve session name extraction for Claude and Codex
This commit is contained in:
@@ -18,6 +18,7 @@ import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
|
||||
import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js';
|
||||
import {
|
||||
createNotificationEvent,
|
||||
notifyRunFailed,
|
||||
@@ -153,11 +154,9 @@ function mapCliOptionsToSDK(options = {}) {
|
||||
// Since SDK 0.2.113, options.env replaces process.env instead of overlaying it.
|
||||
sdkOptions.env = { ...process.env };
|
||||
|
||||
// Use CLAUDE_CLI_PATH if explicitly set, otherwise fall back to 'claude' on PATH.
|
||||
// The SDK 0.2.113+ looks for a bundled native binary optional dep by default;
|
||||
// this fallback ensures users who installed via the official installer still work
|
||||
// even when npm prune --production has removed those optional deps.
|
||||
sdkOptions.pathToClaudeCodeExecutable = process.env.CLAUDE_CLI_PATH || 'claude';
|
||||
// Resolve the executable eagerly on Windows because the SDK uses raw child_process.spawn,
|
||||
// which does not reliably follow npm's shell wrappers like cross-spawn does.
|
||||
sdkOptions.pathToClaudeCodeExecutable = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH);
|
||||
|
||||
// Map working directory
|
||||
if (cwd) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
||||
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
import { resolveClaudeCodeExecutablePath } from '@/shared/claude-cli-path.js';
|
||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||
import type { ProviderAuthStatus } from '@/shared/types.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.
|
||||
*/
|
||||
private checkInstalled(): boolean {
|
||||
const cliPath = process.env.CLAUDE_CLI_PATH || 'claude';
|
||||
try {
|
||||
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const cliPath = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH);
|
||||
try {
|
||||
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import {
|
||||
@@ -91,7 +92,7 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
filePath: string,
|
||||
nameMap: Map<string, string>
|
||||
): Promise<ParsedSession | null> {
|
||||
return extractFirstValidJsonlData(filePath, (rawData) => {
|
||||
const parsed = await extractFirstValidJsonlData(filePath, (rawData) => {
|
||||
const data = rawData as Record<string, unknown>;
|
||||
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined;
|
||||
const projectPath = typeof data.cwd === 'string' ? data.cwd : undefined;
|
||||
@@ -103,8 +104,68 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
return {
|
||||
sessionId,
|
||||
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 path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import {
|
||||
@@ -99,7 +100,7 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
filePath: string,
|
||||
nameMap: Map<string, string>
|
||||
): Promise<ParsedSession | null> {
|
||||
return extractFirstValidJsonlData(filePath, (rawData) => {
|
||||
const parsed = await extractFirstValidJsonlData(filePath, (rawData) => {
|
||||
const data = rawData as Record<string, unknown>;
|
||||
const payload = data.payload as Record<string, unknown> | undefined;
|
||||
const sessionId = typeof payload?.id === 'string' ? payload.id : undefined;
|
||||
@@ -112,8 +113,67 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
return {
|
||||
sessionId,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
47
server/shared/claude-cli-path.test.ts
Normal file
47
server/shared/claude-cli-path.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
resolveClaudeCodeExecutablePath,
|
||||
type ResolveClaudeCodeExecutablePathDependencies,
|
||||
} from '@/shared/claude-cli-path.js';
|
||||
|
||||
test('resolveClaudeCodeExecutablePath resolves the npm Claude wrapper to its native exe on Windows', () => {
|
||||
const wrapperDir = 'C:\\nvm4w\\nodejs';
|
||||
const nativePath = `${wrapperDir}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`;
|
||||
const execFileSync =
|
||||
(() => `${wrapperDir}\\claude\r\n${wrapperDir}\\claude.cmd\r\n`) as unknown as ResolveClaudeCodeExecutablePathDependencies['execFileSync'];
|
||||
const readFileSync = (() => '') as unknown as ResolveClaudeCodeExecutablePathDependencies['readFileSync'];
|
||||
|
||||
const resolved = resolveClaudeCodeExecutablePath('claude', {
|
||||
platform: 'win32',
|
||||
execFileSync,
|
||||
existsSync: (candidate) => candidate === nativePath,
|
||||
readFileSync,
|
||||
});
|
||||
|
||||
assert.equal(resolved, nativePath);
|
||||
});
|
||||
|
||||
test('resolveClaudeCodeExecutablePath keeps an explicit JavaScript launcher path unchanged', () => {
|
||||
const scriptPath = 'C:\\tools\\claude.js';
|
||||
|
||||
const resolved = resolveClaudeCodeExecutablePath(scriptPath, {
|
||||
platform: 'win32',
|
||||
});
|
||||
|
||||
assert.equal(resolved, scriptPath);
|
||||
});
|
||||
|
||||
test('resolveClaudeCodeExecutablePath falls back to the configured command when PATH lookup fails', () => {
|
||||
const execFileSync = (() => {
|
||||
throw new Error('not found');
|
||||
}) as unknown as ResolveClaudeCodeExecutablePathDependencies['execFileSync'];
|
||||
|
||||
const resolved = resolveClaudeCodeExecutablePath('claude', {
|
||||
platform: 'win32',
|
||||
execFileSync,
|
||||
});
|
||||
|
||||
assert.equal(resolved, 'claude');
|
||||
});
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user