mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-16 03:52:17 +08:00
Compare commits
1 Commits
fix/codex-
...
feat/upgra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a0c8b20e5 |
@@ -161,8 +161,6 @@ export default tseslint.config(
|
||||
"server/shared/utils.{js,ts}",
|
||||
"server/shared/frontmatter.ts",
|
||||
"server/shared/claude-cli-path.ts",
|
||||
"server/shared/cli-runtime-env.ts",
|
||||
"server/shared/codex-cli-runtime.ts",
|
||||
], // classify shared utility files so modules can depend on them explicitly
|
||||
mode: "file",
|
||||
},
|
||||
|
||||
@@ -112,17 +112,7 @@ const wss = createWebSocketServer(server, {
|
||||
getPendingApprovalsForSession,
|
||||
},
|
||||
shell: {
|
||||
resolveProviderSessionId: (sessionId, provider) => {
|
||||
const dbSession = sessionsDb.getSessionById(sessionId);
|
||||
const legacyGeminiSession =
|
||||
provider === 'gemini' ? sessionManager.getSession(sessionId) : null;
|
||||
|
||||
if (dbSession) {
|
||||
return dbSession.provider_session_id ?? legacyGeminiSession?.cliSessionId ?? null;
|
||||
}
|
||||
|
||||
return legacyGeminiSession?.cliSessionId;
|
||||
},
|
||||
getSessionById: (sessionId) => sessionManager.getSession(sessionId),
|
||||
stripAnsiSequences,
|
||||
normalizeDetectedUrl,
|
||||
extractUrlsFromText,
|
||||
@@ -1135,6 +1125,7 @@ app.post('/api/projects/:projectId/upload-images', authenticateToken, async (req
|
||||
app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { projectId, sessionId } = req.params;
|
||||
const { provider = 'claude' } = req.query;
|
||||
const homeDir = os.homedir();
|
||||
|
||||
// Allow only safe characters in sessionId
|
||||
@@ -1145,14 +1136,8 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
||||
|
||||
// Provider artifacts on disk (JSONL file names, OpenCode sqlite rows)
|
||||
// are keyed by the provider-native session id, while the caller sends
|
||||
// the app-facing id. Resolve provider and id mapping from the indexed
|
||||
// session row so the frontend does not choose provider-specific paths.
|
||||
// the app-facing id. Resolve the mapping once for all branches below.
|
||||
const sessionRow = sessionsDb.getSessionById(safeSessionId);
|
||||
if (!sessionRow) {
|
||||
return res.status(404).json({ error: 'Session not found', sessionId: safeSessionId });
|
||||
}
|
||||
|
||||
const provider = sessionRow.provider || 'claude';
|
||||
const providerNativeSessionId = sessionRow?.provider_session_id || safeSessionId;
|
||||
|
||||
// Handle Cursor sessions - they use SQLite and don't have token usage info
|
||||
|
||||
@@ -70,15 +70,3 @@ test('createSession reactivates archived rows when the session becomes active ag
|
||||
assert.equal(restoredSession?.isArchived, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('repository reads normalize SQLite UTC timestamps to ISO strings', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createAppSession('session-timezone', 'claude', '/workspace/demo-project');
|
||||
|
||||
const row = sessionsDb.getSessionById('session-timezone');
|
||||
assert.ok(row?.created_at.endsWith('Z'));
|
||||
assert.ok(row?.updated_at.endsWith('Z'));
|
||||
assert.match(row?.created_at ?? '', /^\d{4}-\d{2}-\d{2}T/);
|
||||
assert.match(row?.updated_at ?? '', /^\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,19 +17,10 @@ type SessionRow = {
|
||||
const SESSION_ROW_COLUMNS =
|
||||
'session_id, provider, provider_session_id, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at';
|
||||
|
||||
const SQLITE_UTC_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
|
||||
|
||||
function normalizeTimestamp(value?: string): string | null {
|
||||
if (!value) return null;
|
||||
|
||||
// SQLite CURRENT_TIMESTAMP is stored as UTC without a timezone suffix.
|
||||
// Normalize it here so every session reader returns canonical ISO strings
|
||||
// and the sidebar never interprets fresh rows as local-time "hours old".
|
||||
const normalizedValue = SQLITE_UTC_TIMESTAMP_REGEX.test(value)
|
||||
? `${value.replace(' ', 'T')}Z`
|
||||
: value;
|
||||
|
||||
const parsed = new Date(normalizedValue);
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null;
|
||||
}
|
||||
@@ -37,22 +28,6 @@ function normalizeTimestamp(value?: string): string | null {
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
function normalizeSessionRow<T extends SessionRow | null | undefined>(row: T): T {
|
||||
if (!row) {
|
||||
return row;
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
created_at: normalizeTimestamp(row.created_at) ?? row.created_at,
|
||||
updated_at: normalizeTimestamp(row.updated_at) ?? row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSessionRows(rows: SessionRow[]): SessionRow[] {
|
||||
return rows.map((row) => normalizeSessionRow(row) as SessionRow);
|
||||
}
|
||||
|
||||
function normalizeProjectPathForProvider(provider: string, projectPath: string): string {
|
||||
void provider;
|
||||
return normalizeProjectPath(projectPath);
|
||||
@@ -232,7 +207,7 @@ export const sessionsDb = {
|
||||
)
|
||||
.get(sessionId) as SessionRow | undefined;
|
||||
|
||||
return normalizeSessionRow(row) ?? null;
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -254,57 +229,18 @@ export const sessionsDb = {
|
||||
)
|
||||
.get(providerSessionId) as SessionRow | undefined;
|
||||
|
||||
return normalizeSessionRow(row) ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Finds the newest app-created session for a project that is still waiting
|
||||
* for its provider-native id to be recorded.
|
||||
*
|
||||
* Primary intention: OpenCode can expose a new session in its shared
|
||||
* `opencode.db` before the websocket runtime reports that same provider id
|
||||
* back to our app. At that moment the sidebar already has an optimistic
|
||||
* app-owned session row, but the watcher only knows the provider-native id.
|
||||
*
|
||||
* Without this lookup, the synchronizer would insert a second row keyed by
|
||||
* the provider id, then `assignProviderSessionId()` would merge it a moment
|
||||
* later. That eventually self-heals, but on slow networks the user can still
|
||||
* briefly see two sidebar sessions for the same conversation.
|
||||
*
|
||||
* This helper lets the synchronizer claim the pending app row first, so the
|
||||
* provider id is attached before any watcher-created row exists. The result
|
||||
* is simpler than frontend dedupe and keeps the race resolved at the source.
|
||||
*/
|
||||
findLatestPendingAppSession(provider: string, projectPath: string): SessionRow | null {
|
||||
const db = getConnection();
|
||||
const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath);
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT ${SESSION_ROW_COLUMNS}
|
||||
FROM sessions
|
||||
WHERE provider = ?
|
||||
AND project_path = ?
|
||||
AND provider_session_id IS NULL
|
||||
AND isArchived = 0
|
||||
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(provider, normalizedProjectPath) as SessionRow | undefined;
|
||||
|
||||
return normalizeSessionRow(row) ?? null;
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
getAllSessions(): SessionRow[] {
|
||||
const db = getConnection();
|
||||
const rows = db
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT ${SESSION_ROW_COLUMNS}
|
||||
FROM sessions
|
||||
WHERE isArchived = 0`
|
||||
)
|
||||
.all() as SessionRow[];
|
||||
|
||||
return normalizeSessionRows(rows);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -313,7 +249,7 @@ export const sessionsDb = {
|
||||
*/
|
||||
getArchivedSessions(): SessionRow[] {
|
||||
const db = getConnection();
|
||||
const rows = db
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT ${SESSION_ROW_COLUMNS}
|
||||
FROM sessions
|
||||
@@ -321,14 +257,12 @@ export const sessionsDb = {
|
||||
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC`
|
||||
)
|
||||
.all() as SessionRow[];
|
||||
|
||||
return normalizeSessionRows(rows);
|
||||
},
|
||||
|
||||
getSessionsByProjectPath(projectPath: string): SessionRow[] {
|
||||
const db = getConnection();
|
||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||
const rows = db
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT ${SESSION_ROW_COLUMNS}
|
||||
FROM sessions
|
||||
@@ -336,8 +270,6 @@ export const sessionsDb = {
|
||||
AND isArchived = 0`
|
||||
)
|
||||
.all(normalizedProjectPath) as SessionRow[];
|
||||
|
||||
return normalizeSessionRows(rows);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -347,21 +279,19 @@ export const sessionsDb = {
|
||||
getSessionsByProjectPathIncludingArchived(projectPath: string): SessionRow[] {
|
||||
const db = getConnection();
|
||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||
const rows = db
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT ${SESSION_ROW_COLUMNS}
|
||||
FROM sessions
|
||||
WHERE project_path = ?`
|
||||
)
|
||||
.all(normalizedProjectPath) as SessionRow[];
|
||||
|
||||
return normalizeSessionRows(rows);
|
||||
},
|
||||
|
||||
getSessionsByProjectPathPage(projectPath: string, limit: number, offset: number): SessionRow[] {
|
||||
const db = getConnection();
|
||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||
const rows = db
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT ${SESSION_ROW_COLUMNS}
|
||||
FROM sessions
|
||||
@@ -371,8 +301,6 @@ export const sessionsDb = {
|
||||
LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(normalizedProjectPath, limit, offset) as SessionRow[];
|
||||
|
||||
return normalizeSessionRows(rows);
|
||||
},
|
||||
|
||||
countSessionsByProjectPath(projectPath: string): number {
|
||||
|
||||
@@ -30,6 +30,10 @@ type ProjectApiView = {
|
||||
isArchived: boolean;
|
||||
isStarred: boolean;
|
||||
sessions: [];
|
||||
cursorSessions: [];
|
||||
codexSessions: [];
|
||||
geminiSessions: [];
|
||||
opencodeSessions: [];
|
||||
sessionMeta: {
|
||||
hasMore: false;
|
||||
total: 0;
|
||||
@@ -78,6 +82,10 @@ function mapProjectRowToApiView(projectRow: ProjectRepositoryRow): ProjectApiVie
|
||||
isArchived: Boolean(projectRow.isArchived),
|
||||
isStarred: Boolean(projectRow.isStarred),
|
||||
sessions: [],
|
||||
cursorSessions: [],
|
||||
codexSessions: [],
|
||||
geminiSessions: [],
|
||||
opencodeSessions: [],
|
||||
sessionMeta: {
|
||||
hasMore: false,
|
||||
total: 0,
|
||||
|
||||
@@ -9,12 +9,13 @@ import { AppError } from '@/shared/utils.js';
|
||||
|
||||
type SessionSummary = {
|
||||
id: string;
|
||||
provider: string;
|
||||
summary: string;
|
||||
messageCount: number;
|
||||
lastActivity: string;
|
||||
};
|
||||
|
||||
type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode', SessionSummary[]>;
|
||||
|
||||
type SessionRepositoryRow = {
|
||||
provider: string;
|
||||
session_id: string;
|
||||
@@ -30,6 +31,10 @@ export type ProjectListItem = {
|
||||
fullPath: string;
|
||||
isStarred: boolean;
|
||||
sessions: SessionSummary[];
|
||||
cursorSessions: SessionSummary[];
|
||||
codexSessions: SessionSummary[];
|
||||
geminiSessions: SessionSummary[];
|
||||
opencodeSessions: SessionSummary[];
|
||||
sessionMeta: {
|
||||
hasMore: boolean;
|
||||
total: number;
|
||||
@@ -59,7 +64,7 @@ type SessionPaginationOptions = {
|
||||
};
|
||||
|
||||
type ProjectSessionsPageResult = {
|
||||
sessions: SessionSummary[];
|
||||
sessionsByProvider: SessionsByProvider;
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
@@ -67,6 +72,10 @@ type ProjectSessionsPageResult = {
|
||||
export type ProjectSessionsPageApiView = {
|
||||
projectId: string;
|
||||
sessions: SessionSummary[];
|
||||
cursorSessions: SessionSummary[];
|
||||
codexSessions: SessionSummary[];
|
||||
geminiSessions: SessionSummary[];
|
||||
opencodeSessions: SessionSummary[];
|
||||
sessionMeta: {
|
||||
hasMore: boolean;
|
||||
total: number;
|
||||
@@ -120,18 +129,39 @@ function normalizeSessionPagination(options: SessionPaginationOptions = {}): { l
|
||||
function mapSessionRowToSummary(row: SessionRepositoryRow): SessionSummary {
|
||||
return {
|
||||
id: row.session_id,
|
||||
provider: row.provider,
|
||||
summary: row.custom_name || '',
|
||||
messageCount: 0,
|
||||
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function bucketSessionRowsByProvider(rows: SessionRepositoryRow[]): SessionsByProvider {
|
||||
const byProvider: SessionsByProvider = {
|
||||
claude: [],
|
||||
cursor: [],
|
||||
codex: [],
|
||||
gemini: [],
|
||||
opencode: [],
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
const provider = row.provider as keyof SessionsByProvider;
|
||||
const bucket = byProvider[provider];
|
||||
if (!bucket) {
|
||||
continue;
|
||||
}
|
||||
|
||||
bucket.push(mapSessionRowToSummary(row));
|
||||
}
|
||||
|
||||
return byProvider;
|
||||
}
|
||||
|
||||
function readProjectSessionsIncludingArchived(projectPath: string): ProjectSessionsPageResult {
|
||||
const rows = sessionsDb.getSessionsByProjectPathIncludingArchived(projectPath) as SessionRepositoryRow[];
|
||||
|
||||
return {
|
||||
sessions: rows.map(mapSessionRowToSummary),
|
||||
sessionsByProvider: bucketSessionRowsByProvider(rows),
|
||||
total: rows.length,
|
||||
hasMore: false,
|
||||
};
|
||||
@@ -153,7 +183,7 @@ function readProjectSessionsPageByPath(
|
||||
const total = sessionsDb.countSessionsByProjectPath(projectPath);
|
||||
|
||||
return {
|
||||
sessions: rows.map(mapSessionRowToSummary),
|
||||
sessionsByProvider: bucketSessionRowsByProvider(rows),
|
||||
total,
|
||||
hasMore: pagination.offset + rows.length < total,
|
||||
};
|
||||
@@ -175,7 +205,7 @@ function broadcastProgress(progress: ProgressUpdate) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads all projects from DB and returns normalized session summaries.
|
||||
* Reads all projects from DB and returns provider-bucketed session summaries.
|
||||
*/
|
||||
export async function getProjectsWithSessions(
|
||||
options: GetProjectsWithSessionsOptions = {}
|
||||
@@ -223,7 +253,11 @@ export async function getProjectsWithSessions(
|
||||
displayName,
|
||||
fullPath: projectPath,
|
||||
isStarred: Boolean(row.isStarred),
|
||||
sessions: sessionsPage.sessions,
|
||||
sessions: sessionsPage.sessionsByProvider.claude,
|
||||
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
||||
codexSessions: sessionsPage.sessionsByProvider.codex,
|
||||
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
||||
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
|
||||
sessionMeta: {
|
||||
hasMore: sessionsPage.hasMore,
|
||||
total: sessionsPage.total,
|
||||
@@ -276,7 +310,11 @@ export async function getArchivedProjectsWithSessions(
|
||||
fullPath: row.project_path,
|
||||
isStarred: Boolean(row.isStarred),
|
||||
isArchived: true,
|
||||
sessions: sessionsPage.sessions,
|
||||
sessions: sessionsPage.sessionsByProvider.claude,
|
||||
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
||||
codexSessions: sessionsPage.sessionsByProvider.codex,
|
||||
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
||||
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
|
||||
sessionMeta: {
|
||||
hasMore: sessionsPage.hasMore,
|
||||
total: sessionsPage.total,
|
||||
@@ -305,7 +343,11 @@ export async function getProjectSessionsPage(
|
||||
const sessionsPage = readProjectSessionsPageByPath(projectRow.project_path, options);
|
||||
return {
|
||||
projectId: projectRow.project_id,
|
||||
sessions: sessionsPage.sessions,
|
||||
sessions: sessionsPage.sessionsByProvider.claude,
|
||||
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
||||
codexSessions: sessionsPage.sessionsByProvider.codex,
|
||||
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
||||
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
|
||||
sessionMeta: {
|
||||
hasMore: sessionsPage.hasMore,
|
||||
total: sessionsPage.total,
|
||||
|
||||
@@ -6,7 +6,6 @@ import spawn from 'cross-spawn';
|
||||
|
||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||
import type { ProviderAuthStatus } from '@/shared/types.js';
|
||||
import { createCodexRuntimeEnv, resolveCodexExecutablePath } from '@/shared/codex-cli-runtime.js';
|
||||
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
|
||||
|
||||
type CodexCredentialsStatus = {
|
||||
@@ -22,12 +21,8 @@ export class CodexProviderAuth implements IProviderAuth {
|
||||
*/
|
||||
private checkInstalled(): boolean {
|
||||
try {
|
||||
const result = spawn.sync(resolveCodexExecutablePath(), ['--version'], {
|
||||
env: createCodexRuntimeEnv(),
|
||||
stdio: 'ignore',
|
||||
timeout: 5000,
|
||||
});
|
||||
return !result.error && result.status === 0;
|
||||
spawn.sync('codex', ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -112,17 +112,6 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
|
||||
}
|
||||
|
||||
const fallbackTitle = 'Untitled OpenCode Session';
|
||||
const pendingAppSession = sessionsDb.getSessionByProviderSessionId(sessionId)
|
||||
?? sessionsDb.getSessionById(sessionId)
|
||||
?? sessionsDb.findLatestPendingAppSession(this.provider, projectPath);
|
||||
if (pendingAppSession && !pendingAppSession.provider_session_id) {
|
||||
// Slow networks can let the sqlite watcher index opencode.db before the
|
||||
// runtime reports its provider id back through the websocket mapping.
|
||||
// Bind that id to the fresh app row first so the watcher does not create
|
||||
// a temporary provider-id sidebar entry for the same session.
|
||||
sessionsDb.assignProviderSessionId(pendingAppSession.session_id, sessionId);
|
||||
}
|
||||
|
||||
// App-created sessions are keyed by an app id, so disk-discovered provider
|
||||
// ids must be resolved through the provider-id mapping first.
|
||||
const existingSession = sessionsDb.getSessionByProviderSessionId(sessionId)
|
||||
@@ -134,9 +123,7 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
|
||||
|
||||
// OpenCode stores every session in one shared sqlite database, so jsonl_path
|
||||
// must stay null to avoid deleting opencode.db when one app session is removed.
|
||||
// Return the canonical stored row id so watcher-triggered sidebar updates
|
||||
// stay on the app session once provider_session_id has already been mapped.
|
||||
return sessionsDb.createSession(
|
||||
sessionsDb.createSession(
|
||||
sessionId,
|
||||
this.provider,
|
||||
projectPath,
|
||||
@@ -145,6 +132,8 @@ export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer
|
||||
normalizeProviderTimestamp(row.time_updated ?? row.time_created),
|
||||
null,
|
||||
);
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
private readFirstUserText(db: Database.Database, sessionId: string): string | undefined {
|
||||
|
||||
@@ -272,55 +272,6 @@ test('OpenCode session synchronizer indexes sqlite sessions without deletable tr
|
||||
}
|
||||
});
|
||||
|
||||
test('OpenCode session synchronizer returns the app session id once provider mapping exists', { concurrency: false }, async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-mapped-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await mkdir(workspacePath, { recursive: true });
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
|
||||
try {
|
||||
await createOpenCodeDatabase(tempRoot, workspacePath);
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createAppSession('app-session-1', 'opencode', workspacePath);
|
||||
sessionsDb.assignProviderSessionId('app-session-1', 'open-session-1');
|
||||
|
||||
const synchronizer = new OpenCodeSessionSynchronizer();
|
||||
return synchronizer.synchronizeFile(path.join(tempRoot, '.local', 'share', 'opencode', 'opencode.db')).then((sessionId) => {
|
||||
assert.equal(sessionId, 'app-session-1');
|
||||
assert.equal(sessionsDb.getAllSessions().length, 1);
|
||||
assert.equal(sessionsDb.getSessionById('app-session-1')?.provider_session_id, 'open-session-1');
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('OpenCode session synchronizer adopts the pending app session before watcher sync creates a duplicate', { concurrency: false }, async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-session-sync-race-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await mkdir(workspacePath, { recursive: true });
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
|
||||
try {
|
||||
await createOpenCodeDatabase(tempRoot, workspacePath);
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createAppSession('app-session-race', 'opencode', workspacePath);
|
||||
|
||||
const synchronizer = new OpenCodeSessionSynchronizer();
|
||||
return synchronizer.synchronizeFile(path.join(tempRoot, '.local', 'share', 'opencode', 'opencode.db')).then((sessionId) => {
|
||||
assert.equal(sessionId, 'app-session-race');
|
||||
assert.equal(sessionsDb.getAllSessions().length, 1);
|
||||
assert.equal(sessionsDb.getSessionById('app-session-race')?.provider_session_id, 'open-session-1');
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('OpenCode sessions provider normalizes quoted live text and skips user echoes', () => {
|
||||
const provider = new OpenCodeSessionsProvider();
|
||||
const normalized = provider.normalizeMessage({
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
||||
import { generateDisplayName } from '@/modules/projects/index.js';
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import { ChatSessionWriter } from '@/modules/websocket/services/chat-session-writer.service.js';
|
||||
import { connectedClients, WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js';
|
||||
import type {
|
||||
LLMProvider,
|
||||
NormalizedMessage,
|
||||
@@ -62,48 +58,6 @@ const MAX_BUFFERED_EVENTS_PER_RUN = 5000;
|
||||
*/
|
||||
const runs = new Map<string, ChatRun>();
|
||||
|
||||
async function broadcastCanonicalSessionUpsert(appSessionId: string): Promise<void> {
|
||||
const row = sessionsDb.getSessionById(appSessionId);
|
||||
if (!row || row.isArchived) {
|
||||
return;
|
||||
}
|
||||
|
||||
const projectPath = row.project_path;
|
||||
const project = projectPath ? projectsDb.getProjectPath(projectPath) : null;
|
||||
const displayName = project?.custom_project_name?.trim()
|
||||
? project.custom_project_name
|
||||
: await generateDisplayName(path.basename(projectPath ?? '') || (projectPath ?? ''), projectPath);
|
||||
|
||||
const payload = JSON.stringify({
|
||||
kind: 'session_upserted',
|
||||
sessionId: row.session_id,
|
||||
providerSessionId: row.provider_session_id,
|
||||
provider: row.provider,
|
||||
session: {
|
||||
id: row.session_id,
|
||||
summary: row.custom_name || '',
|
||||
messageCount: 0,
|
||||
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
|
||||
},
|
||||
project: project
|
||||
? {
|
||||
projectId: project.project_id,
|
||||
path: project.project_path,
|
||||
fullPath: project.project_path,
|
||||
displayName,
|
||||
isStarred: Boolean(project.isStarred),
|
||||
}
|
||||
: null,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
connectedClients.forEach((client) => {
|
||||
if (client.readyState === WS_OPEN_STATE) {
|
||||
client.send(payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function evictRunLater(appSessionId: string): void {
|
||||
const timer = setTimeout(() => {
|
||||
const run = runs.get(appSessionId);
|
||||
@@ -178,14 +132,6 @@ function recordProviderSessionId(run: ChatRun, providerSessionId: string): void
|
||||
|
||||
try {
|
||||
sessionsDb.assignProviderSessionId(run.appSessionId, providerSessionId);
|
||||
void broadcastCanonicalSessionUpsert(run.appSessionId).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error('[ChatRunRegistry] Failed to broadcast canonical session mapping', {
|
||||
appSessionId: run.appSessionId,
|
||||
providerSessionId,
|
||||
error: message,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error('[ChatRunRegistry] Failed to persist provider session id mapping', {
|
||||
|
||||
@@ -5,8 +5,7 @@ import path from 'node:path';
|
||||
import pty, { type IPty } from 'node-pty';
|
||||
import { WebSocket, type RawData } from 'ws';
|
||||
|
||||
import { createUserShellRuntimeEnv } from '@/shared/cli-runtime-env.js';
|
||||
import { getCodexShellCommand } from '@/shared/codex-cli-runtime.js';
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import { parseIncomingJsonObject } from '@/shared/utils.js';
|
||||
|
||||
type ShellIncomingMessage = {
|
||||
@@ -37,10 +36,7 @@ const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
|
||||
const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
|
||||
|
||||
type ShellWebSocketDependencies = {
|
||||
resolveProviderSessionId: (
|
||||
sessionId: string,
|
||||
provider: string,
|
||||
) => string | null | undefined;
|
||||
getSessionById: (sessionId: string) => { cliSessionId?: string } | null | undefined;
|
||||
stripAnsiSequences: (content: string) => string;
|
||||
normalizeDetectedUrl: (url: string) => string | null;
|
||||
extractUrlsFromText: (content: string) => string[];
|
||||
@@ -83,32 +79,36 @@ function parseShellMessage(rawMessage: RawData): ShellIncomingMessage | null {
|
||||
|
||||
const SAFE_SESSION_ID_PATTERN = /^[a-zA-Z0-9_.\-:]+$/;
|
||||
|
||||
/**
|
||||
* Maps the app-facing session id to the provider-native id used by CLIs.
|
||||
*
|
||||
* Chat history and provider artifacts on disk are keyed by the provider id,
|
||||
* while the shell UI sends the stable app id from the session gateway.
|
||||
*/
|
||||
function resolveResumeSessionId(
|
||||
message: ShellIncomingMessage,
|
||||
appSessionId: string,
|
||||
provider: string,
|
||||
dependencies: ShellWebSocketDependencies
|
||||
): string {
|
||||
const hasSession = readBoolean(message.hasSession);
|
||||
const sessionId = readString(message.sessionId);
|
||||
const provider = readString(message.provider, 'claude');
|
||||
|
||||
if (!hasSession || !sessionId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let resumeSessionId: string | null | undefined;
|
||||
): string | null {
|
||||
try {
|
||||
resumeSessionId = dependencies.resolveProviderSessionId(sessionId, provider);
|
||||
const sessionRow = sessionsDb.getSessionById(appSessionId);
|
||||
const providerSessionId = sessionRow?.provider_session_id;
|
||||
if (providerSessionId && SAFE_SESSION_ID_PATTERN.test(providerSessionId)) {
|
||||
return providerSessionId;
|
||||
}
|
||||
|
||||
if (provider === 'gemini') {
|
||||
const geminiSession = dependencies.getSessionById(appSessionId);
|
||||
const cliSessionId = geminiSession?.cliSessionId;
|
||||
if (cliSessionId && SAFE_SESSION_ID_PATTERN.test(cliSessionId)) {
|
||||
return cliSessionId;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve provider session ID:', error);
|
||||
resumeSessionId = undefined;
|
||||
console.error(`Failed to resolve resume session id for ${provider}:`, error);
|
||||
}
|
||||
|
||||
const resolvedSessionId = resumeSessionId === undefined ? sessionId : resumeSessionId;
|
||||
if (!resolvedSessionId || !SAFE_SESSION_ID_PATTERN.test(resolvedSessionId)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return resolvedSessionId;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,9 +119,9 @@ function buildShellCommand(
|
||||
dependencies: ShellWebSocketDependencies
|
||||
): string {
|
||||
const hasSession = readBoolean(message.hasSession);
|
||||
const sessionId = readString(message.sessionId);
|
||||
const initialCommand = readString(message.initialCommand);
|
||||
const provider = readString(message.provider, 'claude');
|
||||
const resumeSessionId = resolveResumeSessionId(message, dependencies);
|
||||
const isPlainShell =
|
||||
readBoolean(message.isPlainShell) ||
|
||||
(!!initialCommand && !hasSession) ||
|
||||
@@ -131,45 +131,47 @@ function buildShellCommand(
|
||||
return initialCommand;
|
||||
}
|
||||
|
||||
const resumeId =
|
||||
hasSession && sessionId ? resolveResumeSessionId(sessionId, provider, dependencies) : null;
|
||||
|
||||
if (provider === 'cursor') {
|
||||
if (resumeSessionId) {
|
||||
return `cursor-agent --resume="${resumeSessionId}"`;
|
||||
if (resumeId) {
|
||||
return `cursor-agent --resume="${resumeId}"`;
|
||||
}
|
||||
return 'cursor-agent';
|
||||
}
|
||||
|
||||
if (provider === 'codex') {
|
||||
const codexCommand = getCodexShellCommand();
|
||||
if (resumeSessionId) {
|
||||
if (resumeId) {
|
||||
if (os.platform() === 'win32') {
|
||||
return `${codexCommand} resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { ${codexCommand} }`;
|
||||
return `codex resume "${resumeId}"; if ($LASTEXITCODE -ne 0) { codex }`;
|
||||
}
|
||||
return `${codexCommand} resume "${resumeSessionId}" || ${codexCommand}`;
|
||||
return `codex resume "${resumeId}" || codex`;
|
||||
}
|
||||
return codexCommand;
|
||||
return 'codex';
|
||||
}
|
||||
|
||||
if (provider === 'gemini') {
|
||||
const command = initialCommand || 'gemini';
|
||||
if (resumeSessionId) {
|
||||
return `${command} --resume "${resumeSessionId}"`;
|
||||
if (resumeId) {
|
||||
return `${command} --resume "${resumeId}"`;
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
if (provider === 'opencode') {
|
||||
if (resumeSessionId) {
|
||||
return `opencode --session "${resumeSessionId}"`;
|
||||
if (resumeId) {
|
||||
return `opencode --session "${resumeId}"`;
|
||||
}
|
||||
return initialCommand || 'opencode';
|
||||
}
|
||||
|
||||
const command = initialCommand || 'claude';
|
||||
if (resumeSessionId) {
|
||||
if (resumeId) {
|
||||
if (os.platform() === 'win32') {
|
||||
return `claude --resume "${resumeSessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
|
||||
return `claude --resume "${resumeId}"; if ($LASTEXITCODE -ne 0) { claude }`;
|
||||
}
|
||||
return `claude --resume "${resumeSessionId}" || claude`;
|
||||
return `claude --resume "${resumeId}" || claude`;
|
||||
}
|
||||
return command;
|
||||
}
|
||||
@@ -274,23 +276,17 @@ export function handleShellConnection(
|
||||
return;
|
||||
}
|
||||
|
||||
const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
|
||||
if (sessionId && !safeSessionIdPattern.test(sessionId)) {
|
||||
if (sessionId && !SAFE_SESSION_ID_PATTERN.test(sessionId)) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid session ID' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const shellCommand = buildShellCommand(data, dependencies);
|
||||
const resumeSessionId = resolveResumeSessionId(data, dependencies);
|
||||
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
|
||||
const shellArgs =
|
||||
os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
|
||||
const termCols = readNumber(data.cols, 80);
|
||||
const termRows = readNumber(data.rows, 24);
|
||||
// Plain terminals inherit the server process PATH, which npm can prefix with
|
||||
// /opt/claudecodeui/node_modules/.bin. Put user CLI bins first so shell
|
||||
// commands resolve like the user's login shell instead of the app install.
|
||||
const ptyEnv = createUserShellRuntimeEnv();
|
||||
|
||||
shellProcess = pty.spawn(shell, shellArgs, {
|
||||
name: 'xterm-256color',
|
||||
@@ -298,7 +294,7 @@ export function handleShellConnection(
|
||||
rows: termRows,
|
||||
cwd: resolvedProjectPath,
|
||||
env: {
|
||||
...ptyEnv,
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
FORCE_COLOR: '3',
|
||||
@@ -431,8 +427,8 @@ export function handleShellConnection(
|
||||
: provider === 'opencode'
|
||||
? 'OpenCode'
|
||||
: 'Claude';
|
||||
welcomeMsg = hasSession && resumeSessionId
|
||||
? `\x1b[36mResuming ${providerName} session ${resumeSessionId} in: ${projectPath}\x1b[0m\r\n`
|
||||
welcomeMsg = hasSession
|
||||
? `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n`
|
||||
: `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import test from 'node:test';
|
||||
|
||||
import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
|
||||
import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js';
|
||||
import { connectedClients } from '@/modules/websocket/services/websocket-state.service.js';
|
||||
import type { NormalizedMessage } from '@/shared/types.js';
|
||||
|
||||
/**
|
||||
* Minimal stand-in for a websocket connection: collects every JSON frame the
|
||||
@@ -14,10 +14,10 @@ import { connectedClients } from '@/modules/websocket/services/websocket-state.s
|
||||
*/
|
||||
class FakeConnection {
|
||||
readyState = 1; // WS_OPEN_STATE
|
||||
frames: Array<Record<string, unknown>> = [];
|
||||
frames: NormalizedMessage[] = [];
|
||||
|
||||
send(data: string): void {
|
||||
this.frames.push(JSON.parse(data) as Record<string, unknown>);
|
||||
this.frames.push(JSON.parse(data) as NormalizedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promis
|
||||
try {
|
||||
await runTest();
|
||||
} finally {
|
||||
connectedClients.clear();
|
||||
chatRunRegistry.clearAll();
|
||||
closeConnection();
|
||||
if (previousDatabasePath === undefined) {
|
||||
@@ -73,7 +72,6 @@ test('session_created is swallowed and persisted as the provider-id mapping', as
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createAppSession('app-run-2', 'cursor', '/workspace/demo');
|
||||
const connection = new FakeConnection();
|
||||
connectedClients.add(connection as never);
|
||||
const run = chatRunRegistry.startRun({
|
||||
appSessionId: 'app-run-2',
|
||||
provider: 'cursor',
|
||||
@@ -90,12 +88,9 @@ test('session_created is swallowed and persisted as the provider-id mapping', as
|
||||
newSessionId: 'cursor-native-7',
|
||||
});
|
||||
|
||||
// The provider-native event itself is never forwarded...
|
||||
const sessionUpserts = connection.frames.filter((frame) => frame.kind === 'session_upserted');
|
||||
assert.equal(sessionUpserts.length, 1);
|
||||
assert.equal(sessionUpserts[0]?.sessionId, 'app-run-2');
|
||||
assert.equal(sessionUpserts[0]?.providerSessionId, 'cursor-native-7');
|
||||
// ...but the canonical mapping is recorded and persisted in the database.
|
||||
// Never forwarded to the client...
|
||||
assert.equal(connection.frames.length, 0);
|
||||
// ...but recorded in the registry and persisted in the database.
|
||||
assert.equal(run.providerSessionId, 'cursor-native-7');
|
||||
assert.equal(sessionsDb.getSessionById('app-run-2')?.provider_session_id, 'cursor-native-7');
|
||||
});
|
||||
|
||||
@@ -14,12 +14,10 @@
|
||||
*/
|
||||
|
||||
import { Codex } from '@openai/codex-sdk';
|
||||
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||
import { createCodexRuntimeEnv, resolveCodexExecutablePath } from './shared/codex-cli-runtime.js';
|
||||
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
// Track active sessions
|
||||
@@ -250,11 +248,8 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
const abortController = new AbortController();
|
||||
|
||||
try {
|
||||
// Initialize Codex SDK against the same user/global Codex runtime used by shell terminals.
|
||||
codex = new Codex({
|
||||
codexPathOverride: resolveCodexExecutablePath(),
|
||||
env: createCodexRuntimeEnv(),
|
||||
});
|
||||
// Initialize Codex SDK
|
||||
codex = new Codex();
|
||||
|
||||
// Thread options with sandbox and approval settings
|
||||
const threadOptions = {
|
||||
|
||||
@@ -194,10 +194,6 @@ async function spawnOpenCode(command, options = {}, ws) {
|
||||
|
||||
void providerModelsService.resolveResumeModel('opencode', sessionId, model).then((resolvedModel) => {
|
||||
const args = ['run', '--format', 'json'];
|
||||
// OpenCode's `run` command owns workspace selection through `--dir`.
|
||||
// Relying on the child-process cwd alone is not enough on Linux, where
|
||||
// the CLI can still resolve the session under the server install dir.
|
||||
args.push('--dir', workingDir);
|
||||
if (sessionId) {
|
||||
args.push('--session', sessionId);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
@@ -12,11 +12,6 @@ const findEnvKey = (name) =>
|
||||
async function createFakeOpenCodeExecutable(binDir) {
|
||||
const scriptPath = path.join(binDir, 'opencode.js');
|
||||
await writeFile(scriptPath, `
|
||||
const capturePath = process.env.OPENCODE_ARGS_CAPTURE;
|
||||
if (capturePath) {
|
||||
require('node:fs').writeFileSync(capturePath, JSON.stringify(process.argv.slice(2)));
|
||||
}
|
||||
|
||||
const events = [
|
||||
{ type: 'text', sessionID: 'open-live-1', text: 'assistant response' },
|
||||
{ type: 'step_finish', sessionID: 'open-live-1' },
|
||||
@@ -40,12 +35,10 @@ for (const event of events) {
|
||||
|
||||
test('spawnOpenCode emits session_created before normalized live messages for new sessions', async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'opencode-cli-live-'));
|
||||
const argsCapturePath = path.join(tempRoot, 'opencode-args.json');
|
||||
const pathKey = findEnvKey('PATH');
|
||||
const pathExtKey = findEnvKey('PATHEXT');
|
||||
const previousPath = process.env[pathKey];
|
||||
const previousPathExt = process.env[pathExtKey];
|
||||
const previousArgsCapture = process.env.OPENCODE_ARGS_CAPTURE;
|
||||
const messages = [];
|
||||
const writer = {
|
||||
userId: null,
|
||||
@@ -61,7 +54,6 @@ test('spawnOpenCode emits session_created before normalized live messages for ne
|
||||
try {
|
||||
await createFakeOpenCodeExecutable(tempRoot);
|
||||
process.env[pathKey] = `${tempRoot}${path.delimiter}${previousPath || ''}`;
|
||||
process.env.OPENCODE_ARGS_CAPTURE = argsCapturePath;
|
||||
if (process.platform === 'win32') {
|
||||
process.env[pathExtKey] = previousPathExt?.toUpperCase().includes('.CMD')
|
||||
? previousPathExt
|
||||
@@ -85,11 +77,6 @@ test('spawnOpenCode emits session_created before normalized live messages for ne
|
||||
assert.equal(streamEnd?.sessionId, 'open-live-1');
|
||||
assert.equal(complete?.sessionId, 'open-live-1');
|
||||
assert.equal(messages.some((message) => message.kind === 'error'), false);
|
||||
|
||||
const launchedArgs = JSON.parse(await readFile(argsCapturePath, 'utf8'));
|
||||
assert.ok(Array.isArray(launchedArgs));
|
||||
assert.deepEqual(launchedArgs.slice(0, 4), ['run', '--format', 'json', '--dir']);
|
||||
assert.equal(launchedArgs[4], tempRoot);
|
||||
} finally {
|
||||
if (previousPath === undefined) {
|
||||
delete process.env[pathKey];
|
||||
@@ -103,12 +90,6 @@ test('spawnOpenCode emits session_created before normalized live messages for ne
|
||||
process.env[pathExtKey] = previousPathExt;
|
||||
}
|
||||
|
||||
if (previousArgsCapture === undefined) {
|
||||
delete process.env.OPENCODE_ARGS_CAPTURE;
|
||||
} else {
|
||||
process.env.OPENCODE_ARGS_CAPTURE = previousArgsCapture;
|
||||
}
|
||||
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createUserShellRuntimeEnv } from '@/shared/cli-runtime-env.js';
|
||||
|
||||
const POSIX_PATH_DELIMITER = ':';
|
||||
|
||||
test('createUserShellRuntimeEnv prepends user CLI bins before app-local npm bins', () => {
|
||||
const runtimeEnv = createUserShellRuntimeEnv(
|
||||
{
|
||||
NPM_CONFIG_PREFIX: '/home/devuser/.npm-global',
|
||||
PATH: `/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/bin`,
|
||||
},
|
||||
{
|
||||
homedir: () => '/home/devuser',
|
||||
platform: 'linux',
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
runtimeEnv.PATH,
|
||||
[
|
||||
'/home/devuser/.npm-global/bin',
|
||||
'/home/devuser/.local/bin',
|
||||
'/opt/claudecodeui/node_modules/.bin',
|
||||
'/usr/bin',
|
||||
].join(POSIX_PATH_DELIMITER)
|
||||
);
|
||||
});
|
||||
|
||||
test('createUserShellRuntimeEnv does not duplicate existing user CLI path entries', () => {
|
||||
const runtimeEnv = createUserShellRuntimeEnv(
|
||||
{
|
||||
PATH: [
|
||||
'/home/devuser/.npm-global/bin',
|
||||
'/opt/claudecodeui/node_modules/.bin',
|
||||
'/usr/bin',
|
||||
].join(POSIX_PATH_DELIMITER),
|
||||
},
|
||||
{
|
||||
homedir: () => '/home/devuser',
|
||||
platform: 'linux',
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
runtimeEnv.PATH,
|
||||
[
|
||||
'/home/devuser/.local/bin',
|
||||
'/home/devuser/.npm-global/bin',
|
||||
'/opt/claudecodeui/node_modules/.bin',
|
||||
'/usr/bin',
|
||||
].join(POSIX_PATH_DELIMITER)
|
||||
);
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
export type EnvRecord = Record<string, string | undefined>;
|
||||
|
||||
export type CliRuntimeEnvDependencies = {
|
||||
env?: EnvRecord;
|
||||
homedir?: typeof os.homedir;
|
||||
platform?: NodeJS.Platform;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the path implementation that matches the target runtime platform.
|
||||
*/
|
||||
function getPathApi(platform: NodeJS.Platform) {
|
||||
return platform === 'win32' ? path.win32 : path.posix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the PATH delimiter used by the target runtime platform.
|
||||
*/
|
||||
function getPathDelimiter(platform: NodeJS.Platform): string {
|
||||
return platform === 'win32' ? ';' : ':';
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the environment key that represents PATH, preserving Windows case variants.
|
||||
*/
|
||||
function getPathEnvKey(env: EnvRecord, platform: NodeJS.Platform): string {
|
||||
if (platform !== 'win32') {
|
||||
return 'PATH';
|
||||
}
|
||||
|
||||
return Object.keys(env).find((key) => key.toLowerCase() === 'path') ?? 'Path';
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates non-empty string values while preserving their original order.
|
||||
*/
|
||||
function unique(values: string[]): string[] {
|
||||
return Array.from(new Set(values.filter(Boolean)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a process-style environment object into a string-only environment for child processes.
|
||||
*/
|
||||
export function toStringEnv(env: EnvRecord): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds user/global CLI bin directories that should rank ahead of app-local npm bins.
|
||||
*/
|
||||
export function getPreferredUserCliBinDirectories(
|
||||
dependencies: Required<CliRuntimeEnvDependencies>
|
||||
): string[] {
|
||||
const pathApi = getPathApi(dependencies.platform);
|
||||
const homeDir = dependencies.homedir();
|
||||
const candidates: string[] = [];
|
||||
const npmPrefix = dependencies.env.NPM_CONFIG_PREFIX?.trim();
|
||||
|
||||
if (npmPrefix) {
|
||||
candidates.push(pathApi.join(npmPrefix, dependencies.platform === 'win32' ? '' : 'bin'));
|
||||
}
|
||||
|
||||
if (dependencies.platform === 'win32') {
|
||||
const appData = dependencies.env.APPDATA?.trim();
|
||||
if (appData) {
|
||||
candidates.push(appData, pathApi.join(appData, 'npm'));
|
||||
}
|
||||
candidates.push(pathApi.join(homeDir, 'AppData', 'Roaming', 'npm'));
|
||||
} else {
|
||||
candidates.push(
|
||||
pathApi.join(homeDir, '.npm-global', 'bin'),
|
||||
pathApi.join(homeDir, '.local', 'bin'),
|
||||
);
|
||||
}
|
||||
|
||||
return unique(candidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a provider-neutral shell environment that prefers user/global CLI bins over app-local bins.
|
||||
*/
|
||||
export function createUserShellRuntimeEnv(
|
||||
env: EnvRecord = process.env,
|
||||
dependencies: CliRuntimeEnvDependencies = {}
|
||||
): Record<string, string> {
|
||||
const deps: Required<CliRuntimeEnvDependencies> = {
|
||||
env,
|
||||
homedir: dependencies.homedir ?? os.homedir,
|
||||
platform: dependencies.platform ?? process.platform,
|
||||
};
|
||||
const pathKey = getPathEnvKey(env, deps.platform);
|
||||
const delimiter = getPathDelimiter(deps.platform);
|
||||
const currentPathEntries = (env[pathKey] ?? '').split(delimiter).filter(Boolean);
|
||||
const preferredEntries = getPreferredUserCliBinDirectories(deps).filter(
|
||||
(entry) => !currentPathEntries.includes(entry)
|
||||
);
|
||||
const nextEnv: EnvRecord = { ...env };
|
||||
|
||||
if (preferredEntries.length > 0) {
|
||||
nextEnv[pathKey] = [...preferredEntries, ...currentPathEntries].join(delimiter);
|
||||
}
|
||||
|
||||
return toStringEnv(nextEnv);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createCodexRuntimeEnv,
|
||||
getCodexShellCommand,
|
||||
resolveCodexExecutablePath,
|
||||
type ResolveCodexExecutablePathDependencies,
|
||||
} from '@/shared/codex-cli-runtime.js';
|
||||
|
||||
const POSIX_PATH_DELIMITER = ':';
|
||||
|
||||
function createExistsSync(paths: string[]): ResolveCodexExecutablePathDependencies['existsSync'] {
|
||||
const existing = new Set(paths);
|
||||
return ((candidate: string) => existing.has(candidate)) as ResolveCodexExecutablePathDependencies['existsSync'];
|
||||
}
|
||||
|
||||
test('resolveCodexExecutablePath prefers the user npm-global install over app-local PATH entries', () => {
|
||||
const globalCodexPath = '/home/devuser/.npm-global/bin/codex';
|
||||
const localCodexPath = '/opt/claudecodeui/node_modules/.bin/codex';
|
||||
|
||||
const resolved = resolveCodexExecutablePath(undefined, {
|
||||
env: {
|
||||
NPM_CONFIG_PREFIX: '/home/devuser/.npm-global',
|
||||
PATH: `/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/bin`,
|
||||
},
|
||||
existsSync: createExistsSync([globalCodexPath, localCodexPath]),
|
||||
homedir: () => '/home/devuser',
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
assert.equal(resolved, globalCodexPath);
|
||||
});
|
||||
|
||||
test('resolveCodexExecutablePath skips node_modules bin when a non-local PATH codex exists', () => {
|
||||
const localCodexPath = '/opt/claudecodeui/node_modules/.bin/codex';
|
||||
const pathCodexPath = '/usr/local/bin/codex';
|
||||
|
||||
const resolved = resolveCodexExecutablePath(undefined, {
|
||||
env: {
|
||||
PATH: `/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/local/bin`,
|
||||
},
|
||||
existsSync: createExistsSync([localCodexPath, pathCodexPath]),
|
||||
homedir: () => '/home/devuser',
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
assert.equal(resolved, pathCodexPath);
|
||||
});
|
||||
|
||||
test('resolveCodexExecutablePath falls back to app-local codex when it is the only install', () => {
|
||||
const localCodexPath = '/opt/claudecodeui/node_modules/.bin/codex';
|
||||
|
||||
const resolved = resolveCodexExecutablePath(undefined, {
|
||||
env: {
|
||||
PATH: `/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/bin`,
|
||||
},
|
||||
existsSync: createExistsSync([localCodexPath]),
|
||||
homedir: () => '/home/devuser',
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
assert.equal(resolved, localCodexPath);
|
||||
});
|
||||
|
||||
test('createCodexRuntimeEnv prepends the selected Codex directory to PATH', () => {
|
||||
const runtimeEnv = createCodexRuntimeEnv(
|
||||
{
|
||||
NPM_CONFIG_PREFIX: '/home/devuser/.npm-global',
|
||||
PATH: `/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/bin`,
|
||||
},
|
||||
{
|
||||
existsSync: createExistsSync(['/home/devuser/.npm-global/bin/codex']),
|
||||
homedir: () => '/home/devuser',
|
||||
platform: 'linux',
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
runtimeEnv.PATH,
|
||||
`/home/devuser/.npm-global/bin${POSIX_PATH_DELIMITER}/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/bin`
|
||||
);
|
||||
});
|
||||
|
||||
test('getCodexShellCommand quotes explicit executable paths for shell launches', () => {
|
||||
const command = getCodexShellCommand({
|
||||
env: {
|
||||
CODEX_CLI_PATH: "/home/devuser/bin/codex with space",
|
||||
},
|
||||
existsSync: createExistsSync([]),
|
||||
homedir: () => '/home/devuser',
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
assert.equal(command, "'/home/devuser/bin/codex with space'");
|
||||
});
|
||||
@@ -1,288 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const DEFAULT_CODEX_COMMAND = 'codex';
|
||||
const CODEX_CLI_PATH_ENV_KEYS = ['CODEX_CLI_PATH', 'CLOUDCLI_CODEX_CLI_PATH'] as const;
|
||||
|
||||
/**
|
||||
* Codex runtime precedence:
|
||||
* 1. Explicit CODEX_CLI_PATH or CLOUDCLI_CODEX_CLI_PATH.
|
||||
* 2. User/global installs such as NPM_CONFIG_PREFIX/bin, ~/.npm-global/bin, or ~/.local/bin.
|
||||
* 3. Non-local PATH entries.
|
||||
* 4. App-local node_modules/.bin as the final fallback.
|
||||
*/
|
||||
type EnvRecord = Record<string, string | undefined>;
|
||||
|
||||
export type ResolveCodexExecutablePathDependencies = {
|
||||
env?: EnvRecord;
|
||||
existsSync?: typeof fs.existsSync;
|
||||
homedir?: typeof os.homedir;
|
||||
platform?: NodeJS.Platform;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the path implementation that matches the target runtime platform.
|
||||
*/
|
||||
function getPathApi(platform: NodeJS.Platform) {
|
||||
return platform === 'win32' ? path.win32 : path.posix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the PATH delimiter used by the target runtime platform.
|
||||
*/
|
||||
function getPathDelimiter(platform: NodeJS.Platform): string {
|
||||
return platform === 'win32' ? ';' : ':';
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes one matching pair of surrounding quotes from a configured path value.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a command value looks like a filesystem path instead of a bare command name.
|
||||
*/
|
||||
function isPathLike(value: string, platform: NodeJS.Platform): boolean {
|
||||
return value.includes('/') || value.includes('\\') || getPathApi(platform).isAbsolute(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the environment key that represents PATH, preserving Windows case variants.
|
||||
*/
|
||||
function getPathEnvKey(env: EnvRecord, platform: NodeJS.Platform): string {
|
||||
if (platform !== 'win32') {
|
||||
return 'PATH';
|
||||
}
|
||||
|
||||
return Object.keys(env).find((key) => key.toLowerCase() === 'path') ?? 'Path';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Codex executable filenames to probe for the target platform.
|
||||
*/
|
||||
function getExecutableNames(platform: NodeJS.Platform): string[] {
|
||||
if (platform !== 'win32') {
|
||||
return [DEFAULT_CODEX_COMMAND];
|
||||
}
|
||||
|
||||
return ['codex.exe', 'codex.cmd', 'codex.bat', 'codex.ps1', DEFAULT_CODEX_COMMAND];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates non-empty string values while preserving their original order.
|
||||
*/
|
||||
function unique(values: string[]): string[] {
|
||||
return Array.from(new Set(values.filter(Boolean)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects app-local npm bin directories so they can be treated as a fallback.
|
||||
*/
|
||||
function isNodeModulesBinPath(directoryPath: string, platform: NodeJS.Platform): boolean {
|
||||
const pathApi = getPathApi(platform);
|
||||
const normalized = directoryPath.replace(/[\\/]+$/, '');
|
||||
return (
|
||||
pathApi.basename(normalized).toLowerCase() === '.bin' &&
|
||||
pathApi.basename(pathApi.dirname(normalized)).toLowerCase() === 'node_modules'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the first Codex executable that exists inside one directory.
|
||||
*/
|
||||
function resolveExecutableInDirectory(
|
||||
directoryPath: string,
|
||||
deps: Required<ResolveCodexExecutablePathDependencies>
|
||||
): string | null {
|
||||
const pathApi = getPathApi(deps.platform);
|
||||
for (const executableName of getExecutableNames(deps.platform)) {
|
||||
const candidate = pathApi.join(directoryPath, executableName);
|
||||
if (deps.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an explicit Codex executable override from supported environment variables.
|
||||
*/
|
||||
function getConfiguredCodexPath(env: EnvRecord): string | null {
|
||||
for (const key of CODEX_CLI_PATH_ENV_KEYS) {
|
||||
const value = env[key]?.trim();
|
||||
if (value) {
|
||||
return stripWrappingQuotes(value);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds user/global Codex install candidates that rank ahead of PATH and app-local installs:
|
||||
* NPM_CONFIG_PREFIX/bin, ~/.npm-global/bin, ~/.local/bin, or the Windows npm user folders.
|
||||
*/
|
||||
function getPreferredUserInstallCandidates(
|
||||
deps: Required<ResolveCodexExecutablePathDependencies>
|
||||
): string[] {
|
||||
const pathApi = getPathApi(deps.platform);
|
||||
const homeDir = deps.homedir();
|
||||
const candidates: string[] = [];
|
||||
const npmPrefix = deps.env.NPM_CONFIG_PREFIX?.trim();
|
||||
|
||||
if (npmPrefix) {
|
||||
candidates.push(pathApi.join(npmPrefix, deps.platform === 'win32' ? '' : 'bin'));
|
||||
}
|
||||
|
||||
if (deps.platform === 'win32') {
|
||||
const appData = deps.env.APPDATA?.trim();
|
||||
if (appData) {
|
||||
candidates.push(appData, pathApi.join(appData, 'npm'));
|
||||
}
|
||||
candidates.push(pathApi.join(homeDir, 'AppData', 'Roaming', 'npm'));
|
||||
} else {
|
||||
candidates.push(
|
||||
pathApi.join(homeDir, '.npm-global', 'bin'),
|
||||
pathApi.join(homeDir, '.local', 'bin'),
|
||||
);
|
||||
}
|
||||
|
||||
return unique(candidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches PATH for Codex after user/global candidates, keeping node_modules/.bin as the last fallback.
|
||||
*/
|
||||
function resolveFromPath(
|
||||
deps: Required<ResolveCodexExecutablePathDependencies>
|
||||
): string | null {
|
||||
const pathKey = getPathEnvKey(deps.env, deps.platform);
|
||||
const pathValue = deps.env[pathKey] ?? '';
|
||||
const directories = unique(pathValue.split(getPathDelimiter(deps.platform)).filter(Boolean));
|
||||
let nodeModulesFallback: string | null = null;
|
||||
|
||||
for (const directory of directories) {
|
||||
const candidate = resolveExecutableInDirectory(directory, deps);
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isNodeModulesBinPath(directory, deps.platform)) {
|
||||
nodeModulesFallback ??= candidate;
|
||||
continue;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return nodeModulesFallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a process-style environment object into a string-only environment for child processes.
|
||||
*/
|
||||
function toStringEnv(env: EnvRecord): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Codex executable path for all backend entry points in this order:
|
||||
* explicit CODEX_CLI_PATH/CLOUDCLI_CODEX_CLI_PATH, user/global installs, non-local PATH, app-local PATH.
|
||||
*/
|
||||
export function resolveCodexExecutablePath(
|
||||
configuredPath: string | undefined = undefined,
|
||||
dependencies: ResolveCodexExecutablePathDependencies = {}
|
||||
): string {
|
||||
const deps: Required<ResolveCodexExecutablePathDependencies> = {
|
||||
env: dependencies.env ?? process.env,
|
||||
existsSync: dependencies.existsSync ?? fs.existsSync,
|
||||
homedir: dependencies.homedir ?? os.homedir,
|
||||
platform: dependencies.platform ?? process.platform,
|
||||
};
|
||||
|
||||
const normalizedConfiguredPath = stripWrappingQuotes(
|
||||
configuredPath?.trim() || getConfiguredCodexPath(deps.env) || ''
|
||||
);
|
||||
if (normalizedConfiguredPath) {
|
||||
if (!isPathLike(normalizedConfiguredPath, deps.platform)) {
|
||||
return resolveFromPath(deps) ?? normalizedConfiguredPath;
|
||||
}
|
||||
return normalizedConfiguredPath;
|
||||
}
|
||||
|
||||
for (const candidateDirectory of getPreferredUserInstallCandidates(deps)) {
|
||||
const candidate = resolveExecutableInDirectory(candidateDirectory, deps);
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return resolveFromPath(deps) ?? DEFAULT_CODEX_COMMAND;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Codex child-process environment with the selected runtime directory first on PATH,
|
||||
* preserving the same source precedence as resolveCodexExecutablePath.
|
||||
*/
|
||||
export function createCodexRuntimeEnv(
|
||||
env: EnvRecord = process.env,
|
||||
dependencies: ResolveCodexExecutablePathDependencies = {}
|
||||
): Record<string, string> {
|
||||
const platform = dependencies.platform ?? process.platform;
|
||||
const pathApi = getPathApi(platform);
|
||||
const resolvedCodexPath = resolveCodexExecutablePath(undefined, {
|
||||
...dependencies,
|
||||
env,
|
||||
platform,
|
||||
});
|
||||
const pathKey = getPathEnvKey(env, platform);
|
||||
const currentPath = env[pathKey] ?? '';
|
||||
const resolvedDirectory = isPathLike(resolvedCodexPath, platform)
|
||||
? pathApi.dirname(resolvedCodexPath)
|
||||
: '';
|
||||
const nextEnv: EnvRecord = { ...env };
|
||||
|
||||
if (resolvedDirectory) {
|
||||
const delimiter = getPathDelimiter(platform);
|
||||
const pathEntries = currentPath.split(delimiter).filter(Boolean);
|
||||
if (!pathEntries.includes(resolvedDirectory)) {
|
||||
nextEnv[pathKey] = currentPath
|
||||
? `${resolvedDirectory}${delimiter}${currentPath}`
|
||||
: resolvedDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
return toStringEnv(nextEnv);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the shell-safe Codex command used by interactive PTY launches.
|
||||
*/
|
||||
export function getCodexShellCommand(
|
||||
dependencies: ResolveCodexExecutablePathDependencies = {}
|
||||
): string {
|
||||
const platform = dependencies.platform ?? process.platform;
|
||||
const resolvedCodexPath = resolveCodexExecutablePath(undefined, dependencies);
|
||||
if (!isPathLike(resolvedCodexPath, platform)) {
|
||||
return resolvedCodexPath;
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
return `& '${resolvedCodexPath.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
return `'${resolvedCodexPath.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { authenticatedFetch } from '../../../utils/api';
|
||||
import type { MarkSessionIdle, SessionActivityMap } from '../../../hooks/useSessionProtection';
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
||||
import type { ChatMessage } from '../types/types';
|
||||
import type { ChatMessage, Provider } from '../types/types';
|
||||
import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms';
|
||||
|
||||
import { normalizedToChatMessages } from './useChatMessages';
|
||||
@@ -328,12 +328,18 @@ export function useChatSessionState({
|
||||
if (allMessagesLoadedRef.current) return false;
|
||||
if (!hasMoreMessages || !selectedSession || !selectedProject) return false;
|
||||
|
||||
const sessionProvider = selectedSession.__provider || 'claude';
|
||||
|
||||
isLoadingMoreRef.current = true;
|
||||
const previousScrollHeight = container.scrollHeight;
|
||||
const previousScrollTop = container.scrollTop;
|
||||
|
||||
try {
|
||||
const slot = await sessionStore.fetchMore(selectedSession.id, {
|
||||
provider: sessionProvider as LLMProvider,
|
||||
// DB-assigned projectId replaces the legacy folder-derived name.
|
||||
projectId: selectedProject.projectId,
|
||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
||||
limit: MESSAGES_PER_PAGE,
|
||||
});
|
||||
if (!slot || slot.serverMessages.length === 0) return false;
|
||||
@@ -452,7 +458,8 @@ export function useChatSessionState({
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionKey = `${selectedSession.id}:${selectedProject.projectId}`;
|
||||
const provider = (selectedSession.__provider || localStorage.getItem('selected-provider') as Provider) || 'claude';
|
||||
const sessionKey = `${selectedSession.id}:${selectedProject.projectId}:${provider}`;
|
||||
|
||||
// Skip if already loaded and fresh
|
||||
if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSession.id) && !sessionStore.isStale(selectedSession.id)) {
|
||||
@@ -505,6 +512,9 @@ export function useChatSessionState({
|
||||
// Fetch from server → store updates → chatMessages re-derives automatically
|
||||
setIsLoadingSessionMessages(true);
|
||||
sessionStore.fetchFromServer(selectedSession.id, {
|
||||
provider: (selectedSession.__provider || provider) as LLMProvider,
|
||||
projectId: selectedProject.projectId,
|
||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
||||
limit: MESSAGES_PER_PAGE,
|
||||
offset: 0,
|
||||
}).then(slot => {
|
||||
@@ -534,9 +544,15 @@ export function useChatSessionState({
|
||||
|
||||
const reloadExternalMessages = async () => {
|
||||
try {
|
||||
const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
|
||||
|
||||
// Skip store refresh during active streaming
|
||||
if (!isProcessing) {
|
||||
await sessionStore.refreshFromServer(selectedSession.id);
|
||||
await sessionStore.refreshFromServer(selectedSession.id, {
|
||||
provider: (selectedSession.__provider || provider) as LLMProvider,
|
||||
projectId: selectedProject.projectId,
|
||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
||||
});
|
||||
|
||||
if (Boolean(autoScrollToBottom) && isNearBottom()) {
|
||||
setTimeout(() => scrollToBottom(), 200);
|
||||
@@ -582,9 +598,13 @@ export function useChatSessionState({
|
||||
|
||||
const scrollToTarget = async () => {
|
||||
if (!allMessagesLoadedRef.current && selectedSession && selectedProject) {
|
||||
const sessionProvider = selectedSession.__provider || 'claude';
|
||||
try {
|
||||
// Load all messages into the store for search navigation
|
||||
const slot = await sessionStore.fetchFromServer(selectedSession.id, {
|
||||
provider: sessionProvider as LLMProvider,
|
||||
projectId: selectedProject.projectId,
|
||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
||||
limit: null,
|
||||
offset: 0,
|
||||
});
|
||||
@@ -658,10 +678,17 @@ export function useChatSessionState({
|
||||
setTokenBudget(null);
|
||||
return;
|
||||
}
|
||||
const sessionProvider = selectedSession.__provider || 'claude';
|
||||
if (sessionProvider !== 'claude' && sessionProvider !== 'codex' && sessionProvider !== 'gemini' && sessionProvider !== 'opencode') {
|
||||
setTokenBudget(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchInitialTokenUsage = async () => {
|
||||
try {
|
||||
// The backend resolves the provider from the indexed session row.
|
||||
const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage`;
|
||||
// Token usage endpoint is now keyed by the DB projectId.
|
||||
const params = new URLSearchParams({ provider: sessionProvider });
|
||||
const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage?${params.toString()}`;
|
||||
const response = await authenticatedFetch(url);
|
||||
if (response.ok) {
|
||||
setTokenBudget(await response.json());
|
||||
@@ -673,7 +700,7 @@ export function useChatSessionState({
|
||||
}
|
||||
};
|
||||
fetchInitialTokenUsage();
|
||||
}, [selectedProject, selectedSession?.id]);
|
||||
}, [selectedProject, selectedSession?.id, selectedSession?.__provider]);
|
||||
|
||||
const visibleMessages = useMemo(() => {
|
||||
if (chatMessages.length <= visibleMessageCount) return chatMessages;
|
||||
@@ -733,6 +760,8 @@ export function useChatSessionState({
|
||||
const loadAllMessages = useCallback(async () => {
|
||||
if (!selectedSession || !selectedProject) return;
|
||||
if (isLoadingAllMessages) return;
|
||||
const sessionProvider = selectedSession.__provider || 'claude';
|
||||
|
||||
const requestSessionId = selectedSession.id;
|
||||
allMessagesLoadedRef.current = true;
|
||||
isLoadingMoreRef.current = true;
|
||||
@@ -745,6 +774,9 @@ export function useChatSessionState({
|
||||
|
||||
try {
|
||||
const slot = await sessionStore.fetchFromServer(requestSessionId, {
|
||||
provider: sessionProvider as LLMProvider,
|
||||
projectId: selectedProject.projectId,
|
||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
||||
limit: null,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useWebSocket } from '../../../contexts/WebSocketContext';
|
||||
import PermissionContext from '../../../contexts/PermissionContext';
|
||||
import { QuickSettingsPanel } from '../../quick-settings-panel';
|
||||
import type { ChatInterfaceProps, Provider } from '../types/types';
|
||||
import type { LLMProvider } from '../../../types/app';
|
||||
import { useChatProviderState } from '../hooks/useChatProviderState';
|
||||
import { useChatSessionState } from '../hooks/useChatSessionState';
|
||||
import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
|
||||
@@ -222,7 +223,16 @@ function ChatInterface({
|
||||
// missed live events, and re-attaches a still-running stream to this socket.
|
||||
const handleWebSocketReconnect = useCallback(async () => {
|
||||
if (!selectedProject || !selectedSession) return;
|
||||
await sessionStore.refreshFromServer(selectedSession.id);
|
||||
const providerVal =
|
||||
selectedSession.__provider
|
||||
|| (localStorage.getItem('selected-provider') as LLMProvider)
|
||||
|| 'claude';
|
||||
await sessionStore.refreshFromServer(selectedSession.id, {
|
||||
provider: providerVal as LLMProvider,
|
||||
// Use DB projectId; legacy folder-derived projectName is no longer accepted here.
|
||||
projectId: selectedProject.projectId,
|
||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
||||
});
|
||||
statusCheckSentAtRef.current.set(selectedSession.id, Date.now());
|
||||
sendMessage({
|
||||
type: 'chat.subscribe',
|
||||
@@ -315,7 +325,6 @@ function ChatInterface({
|
||||
onWheel={handleScroll}
|
||||
onTouchMove={handleScroll}
|
||||
isLoadingSessionMessages={isLoadingSessionMessages}
|
||||
isProcessing={isProcessing}
|
||||
chatMessages={chatMessages}
|
||||
selectedSession={selectedSession}
|
||||
currentSessionId={currentSessionId}
|
||||
|
||||
@@ -19,8 +19,6 @@ interface ChatMessagesPaneProps {
|
||||
onWheel: () => void;
|
||||
onTouchMove: () => void;
|
||||
isLoadingSessionMessages: boolean;
|
||||
/** True while the viewed session has an active provider run in flight. */
|
||||
isProcessing?: boolean;
|
||||
chatMessages: ChatMessage[];
|
||||
selectedSession: ProjectSession | null;
|
||||
currentSessionId: string | null;
|
||||
@@ -70,7 +68,6 @@ export default function ChatMessagesPane({
|
||||
onWheel,
|
||||
onTouchMove,
|
||||
isLoadingSessionMessages,
|
||||
isProcessing = false,
|
||||
chatMessages,
|
||||
selectedSession,
|
||||
currentSessionId,
|
||||
@@ -150,7 +147,7 @@ export default function ChatMessagesPane({
|
||||
onTouchMove={onTouchMove}
|
||||
className="relative flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4"
|
||||
>
|
||||
{(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? (
|
||||
{isLoadingSessionMessages && chatMessages.length === 0 ? (
|
||||
<div className="mt-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-gray-400" />
|
||||
|
||||
@@ -11,6 +11,10 @@ export type SessionResult = {
|
||||
|
||||
interface SessionsResponse {
|
||||
sessions?: ProjectSession[];
|
||||
cursorSessions?: ProjectSession[];
|
||||
codexSessions?: ProjectSession[];
|
||||
geminiSessions?: ProjectSession[];
|
||||
opencodeSessions?: ProjectSession[];
|
||||
}
|
||||
|
||||
export function useSessionsSource(projectId: string | undefined, enabled: boolean) {
|
||||
@@ -25,10 +29,17 @@ export function useSessionsSource(projectId: string | undefined, enabled: boolea
|
||||
);
|
||||
},
|
||||
parse: (data) => {
|
||||
return (data.sessions ?? []).map<SessionResult>((s) => ({
|
||||
const all: ProjectSession[] = [
|
||||
...(data.sessions ?? []),
|
||||
...(data.cursorSessions ?? []),
|
||||
...(data.codexSessions ?? []),
|
||||
...(data.geminiSessions ?? []),
|
||||
...(data.opencodeSessions ?? []),
|
||||
];
|
||||
return all.map<SessionResult>((s) => ({
|
||||
id: s.id,
|
||||
label: (s.title || s.summary || s.name || s.id) as string,
|
||||
provider: (s.__provider || s.provider) as LLMProvider | undefined,
|
||||
provider: s.__provider,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,27 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
type ClaudeLogoProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const ClaudeLogo = ({ className = 'w-5 h-5' }: ClaudeLogoProps) => (
|
||||
<svg
|
||||
viewBox="0 0 512 509.64"
|
||||
role="img"
|
||||
aria-label="Claude"
|
||||
className={className}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill="#D77655"
|
||||
d="M115.612 0h280.775C459.974 0 512 52.026 512 115.612v278.415c0 63.587-52.026 115.612-115.613 115.612H115.612C52.026 509.639 0 457.614 0 394.027V115.612C0 52.026 52.026 0 115.612 0z"
|
||||
/>
|
||||
<path
|
||||
fill="#FCF2EE"
|
||||
fillRule="nonzero"
|
||||
d="M142.27 316.619l73.655-41.326 1.238-3.589-1.238-1.996-3.589-.001-12.31-.759-42.084-1.138-36.498-1.516-35.361-1.896-8.897-1.895-8.34-10.995.859-5.484 7.482-5.03 10.717.935 23.683 1.617 35.537 2.452 25.782 1.517 38.193 3.968h6.064l.86-2.451-2.073-1.517-1.618-1.517-36.776-24.922-39.81-26.338-20.852-15.166-11.273-7.683-5.687-7.204-2.451-15.721 10.237-11.273 13.75.935 3.513.936 13.928 10.716 29.749 23.027 38.848 28.612 5.687 4.727 2.275-1.617.278-1.138-2.553-4.271-21.13-38.193-22.546-38.848-10.035-16.101-2.654-9.655c-.935-3.968-1.617-7.304-1.617-11.374l11.652-15.823 6.445-2.073 15.545 2.073 6.547 5.687 9.655 22.092 15.646 34.78 24.265 47.291 7.103 14.028 3.791 12.992 1.416 3.968 2.449-.001v-2.275l1.997-26.641 3.69-32.707 3.589-42.084 1.239-11.854 5.863-14.206 11.652-7.683 9.099 4.348 7.482 10.716-1.036 6.926-4.449 28.915-8.72 45.294-5.687 30.331h3.313l3.792-3.791 15.342-20.372 25.782-32.227 11.374-12.789 13.27-14.129 8.517-6.724 16.1-.001 11.854 17.617-5.307 18.199-16.581 21.029-13.75 17.819-19.716 26.54-12.309 21.231 1.138 1.694 2.932-.278 44.536-9.479 24.062-4.347 28.714-4.928 12.992 6.066 1.416 6.167-5.106 12.613-30.71 7.583-36.018 7.204-53.636 12.689-.657.48.758.935 24.164 2.275 10.337.556h25.301l47.114 3.514 12.309 8.139 7.381 9.959-1.238 7.583-18.957 9.655-25.579-6.066-59.702-14.205-20.474-5.106-2.83-.001v1.694l17.061 16.682 31.266 28.233 39.152 36.397 1.997 8.999-5.03 7.102-5.307-.758-34.401-25.883-13.27-11.651-30.053-25.302-1.996-.001v2.654l6.926 10.136 36.574 54.975 1.895 16.859-2.653 5.485-9.479 3.311-10.414-1.895-21.408-30.054-22.092-33.844-17.819-30.331-2.173 1.238-10.515 113.261-4.929 5.788-11.374 4.348-9.478-7.204-5.03-11.652 5.03-23.027 6.066-30.052 4.928-23.886 4.449-29.674 2.654-9.858-.177-.657-2.173.278-22.37 30.71-34.021 45.977-26.919 28.815-6.445 2.553-11.173-5.789 1.037-10.337 6.243-9.2 37.257-47.392 22.47-29.371 14.508-16.961-.101-2.451h-.859l-98.954 64.251-17.618 2.275-7.583-7.103.936-11.652 3.589-3.791 29.749-20.474-.101.102.024.101z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
const ClaudeLogo = ({ className = 'w-5 h-5' }: ClaudeLogoProps) => {
|
||||
return (
|
||||
<img src="/icons/claude-ai-icon.svg" alt="Claude" className={className} />
|
||||
);
|
||||
};
|
||||
|
||||
export default ClaudeLogo;
|
||||
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
type CodexLogoProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const CodexLogo = ({ className = 'w-5 h-5' }: CodexLogoProps) => (
|
||||
<svg
|
||||
viewBox="100 100 520 520"
|
||||
role="img"
|
||||
aria-label="Codex"
|
||||
className={className}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M304.246 294.611V249.028C304.246 245.189 305.687 242.309 309.044 240.392L400.692 187.612C413.167 180.415 428.042 177.058 443.394 177.058C500.971 177.058 537.44 221.682 537.44 269.182C537.44 272.54 537.44 276.379 536.959 280.218L441.954 224.558C436.197 221.201 430.437 221.201 424.68 224.558L304.246 294.611ZM518.245 472.145V363.224C518.245 356.505 515.364 351.707 509.608 348.349L389.174 278.296L428.519 255.743C431.877 253.826 434.757 253.826 438.115 255.743L529.762 308.523C556.154 323.879 573.905 356.505 573.905 388.171C573.905 424.636 552.315 458.225 518.245 472.141V472.145ZM275.937 376.182L236.592 353.152C233.235 351.235 231.794 348.354 231.794 344.515V238.956C231.794 187.617 271.139 148.749 324.4 148.749C344.555 148.749 363.264 155.468 379.102 167.463L284.578 222.164C278.822 225.521 275.942 230.319 275.942 237.039V376.186L275.937 376.182ZM360.626 425.122L304.246 393.455V326.283L360.626 294.616L417.002 326.283V393.455L360.626 425.122ZM396.852 570.989C376.698 570.989 357.989 564.27 342.151 552.276L436.674 497.574C442.431 494.217 445.311 489.419 445.311 482.699V343.552L485.138 366.582C488.495 368.499 489.936 371.379 489.936 375.219V480.778C489.936 532.117 450.109 570.985 396.852 570.985V570.989ZM283.134 463.99L191.486 411.211C165.094 395.854 147.343 363.229 147.343 331.562C147.343 294.616 169.415 261.509 203.48 247.593V356.991C203.48 363.71 206.361 368.508 212.117 371.866L332.074 441.437L292.729 463.99C289.372 465.907 286.491 465.907 283.134 463.99ZM277.859 542.68C223.639 542.68 183.813 501.895 183.813 451.514C183.813 447.675 184.294 443.836 184.771 439.997L279.295 494.698C285.051 498.056 290.812 498.056 296.568 494.698L417.002 425.127V470.71C417.002 474.549 415.562 477.429 412.204 479.346L320.557 532.126C308.081 539.323 293.206 542.68 277.854 542.68H277.859ZM396.852 599.776C454.911 599.776 503.37 558.513 514.41 503.812C568.149 489.896 602.696 439.515 602.696 388.176C602.696 354.587 588.303 321.962 562.392 298.45C564.791 288.373 566.231 278.296 566.231 268.224C566.231 199.611 510.571 148.267 446.274 148.267C433.322 148.267 420.846 150.184 408.37 154.505C386.775 133.392 357.026 119.958 324.4 119.958C266.342 119.958 217.883 161.22 206.843 215.921C153.104 229.837 118.557 280.218 118.557 331.557C118.557 365.146 132.95 397.771 158.861 421.283C156.462 431.36 155.022 441.437 155.022 451.51C155.022 520.123 210.682 571.466 274.978 571.466C287.931 571.466 300.407 569.549 312.883 565.228C334.473 586.341 364.222 599.776 396.852 599.776Z"
|
||||
fill="currentColor"
|
||||
const CodexLogo = ({ className = 'w-5 h-5' }: CodexLogoProps) => {
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
return (
|
||||
<img
|
||||
src={isDarkMode ? "/icons/codex-white.svg" : "/icons/codex.svg"}
|
||||
alt="Codex"
|
||||
className={className}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default CodexLogo;
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
type CursorLogoProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const CursorLogo = ({ className = 'w-5 h-5' }: CursorLogoProps) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
aria-label="Cursor"
|
||||
className={className}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11.925 24l10.425-6-10.425-6L1.5 18l10.425 6z"
|
||||
fill="currentColor"
|
||||
opacity=".39"
|
||||
const CursorLogo = ({ className = 'w-5 h-5' }: CursorLogoProps) => {
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
return (
|
||||
<img
|
||||
src={isDarkMode ? "/icons/cursor-white.svg" : "/icons/cursor.svg"}
|
||||
alt="Cursor"
|
||||
className={className}
|
||||
/>
|
||||
<path d="M22.35 18V6L11.925 0v12l10.425 6z" fill="currentColor" opacity=".8" />
|
||||
<path d="M11.925 0L1.5 6v12l10.425-6V0z" fill="currentColor" opacity=".6" />
|
||||
<path d="M22.35 6L11.925 24V12L22.35 6z" fill="currentColor" opacity=".72" />
|
||||
<path d="M22.35 6l-10.425 6L1.5 6h20.85z" fill="currentColor" opacity=".95" />
|
||||
</svg>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default CursorLogo;
|
||||
|
||||
@@ -1,263 +1,7 @@
|
||||
import { useId } from 'react';
|
||||
|
||||
type GeminiLogoProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const GeminiLogo = ({ className = 'w-5 h-5' }: GeminiLogoProps) => {
|
||||
const id = useId().replace(/:/g, '');
|
||||
const maskId = `${id}-gemini-mask`;
|
||||
const gradientId = `${id}-gemini-gradient`;
|
||||
const filterIds = Array.from({ length: 11 }, (_, index) => `${id}-gemini-filter-${index}`);
|
||||
|
||||
const GeminiLogo = ({className = 'w-5 h-5'}) => {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 65 65"
|
||||
role="img"
|
||||
aria-label="Gemini"
|
||||
className={className}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id={maskId}
|
||||
style={{ maskType: 'alpha' }}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="65"
|
||||
height="65"
|
||||
>
|
||||
<path
|
||||
d="M32.447 0c.68 0 1.273.465 1.439 1.125a38.904 38.904 0 001.999 5.905c2.152 5 5.105 9.376 8.854 13.125 3.751 3.75 8.126 6.703 13.125 8.855a38.98 38.98 0 005.906 1.999c.66.166 1.124.758 1.124 1.438 0 .68-.464 1.273-1.125 1.439a38.902 38.902 0 00-5.905 1.999c-5 2.152-9.375 5.105-13.125 8.854-3.749 3.751-6.702 8.126-8.854 13.125a38.973 38.973 0 00-2 5.906 1.485 1.485 0 01-1.438 1.124c-.68 0-1.272-.464-1.438-1.125a38.913 38.913 0 00-2-5.905c-2.151-5-5.103-9.375-8.854-13.125-3.75-3.749-8.125-6.702-13.125-8.854a38.973 38.973 0 00-5.905-2A1.485 1.485 0 010 32.448c0-.68.465-1.272 1.125-1.438a38.903 38.903 0 005.905-2c5-2.151 9.376-5.104 13.125-8.854 3.75-3.749 6.703-8.125 8.855-13.125a38.972 38.972 0 001.999-5.905A1.485 1.485 0 0132.447 0z"
|
||||
fill="#000"
|
||||
/>
|
||||
<path
|
||||
d="M32.447 0c.68 0 1.273.465 1.439 1.125a38.904 38.904 0 001.999 5.905c2.152 5 5.105 9.376 8.854 13.125 3.751 3.75 8.126 6.703 13.125 8.855a38.98 38.98 0 005.906 1.999c.66.166 1.124.758 1.124 1.438 0 .68-.464 1.273-1.125 1.439a38.902 38.902 0 00-5.905 1.999c-5 2.152-9.375 5.105-13.125 8.854-3.749 3.751-6.702 8.126-8.854 13.125a38.973 38.973 0 00-2 5.906 1.485 1.485 0 01-1.438 1.124c-.68 0-1.272-.464-1.438-1.125a38.913 38.913 0 00-2-5.905c-2.151-5-5.103-9.375-8.854-13.125-3.75-3.749-8.125-6.702-13.125-8.854a38.973 38.973 0 00-5.905-2A1.485 1.485 0 010 32.448c0-.68.465-1.272 1.125-1.438a38.903 38.903 0 005.905-2c5-2.151 9.376-5.104 13.125-8.854 3.75-3.749 6.703-8.125 8.855-13.125a38.972 38.972 0 001.999-5.905A1.485 1.485 0 0132.447 0z"
|
||||
fill={`url(#${gradientId})`}
|
||||
/>
|
||||
</mask>
|
||||
<g mask={`url(#${maskId})`}>
|
||||
<g filter={`url(#${filterIds[0]})`}>
|
||||
<path
|
||||
d="M-5.859 50.734c7.498 2.663 16.116-2.33 19.249-11.152 3.133-8.821-.406-18.131-7.904-20.794-7.498-2.663-16.116 2.33-19.25 11.151-3.132 8.822.407 18.132 7.905 20.795z"
|
||||
fill="#FFE432"
|
||||
/>
|
||||
</g>
|
||||
<g filter={`url(#${filterIds[1]})`}>
|
||||
<path
|
||||
d="M27.433 21.649c10.3 0 18.651-8.535 18.651-19.062 0-10.528-8.35-19.062-18.651-19.062S8.78-7.94 8.78 2.587c0 10.527 8.35 19.062 18.652 19.062z"
|
||||
fill="#FC413D"
|
||||
/>
|
||||
</g>
|
||||
<g filter={`url(#${filterIds[2]})`}>
|
||||
<path
|
||||
d="M20.184 82.608c10.753-.525 18.918-12.244 18.237-26.174-.68-13.93-9.95-24.797-20.703-24.271C6.965 32.689-1.2 44.407-.519 58.337c.681 13.93 9.95 24.797 20.703 24.271z"
|
||||
fill="#00B95C"
|
||||
/>
|
||||
</g>
|
||||
<g filter={`url(#${filterIds[3]})`}>
|
||||
<path
|
||||
d="M20.184 82.608c10.753-.525 18.918-12.244 18.237-26.174-.68-13.93-9.95-24.797-20.703-24.271C6.965 32.689-1.2 44.407-.519 58.337c.681 13.93 9.95 24.797 20.703 24.271z"
|
||||
fill="#00B95C"
|
||||
/>
|
||||
</g>
|
||||
<g filter={`url(#${filterIds[4]})`}>
|
||||
<path
|
||||
d="M30.954 74.181c9.014-5.485 11.427-17.976 5.389-27.9-6.038-9.925-18.241-13.524-27.256-8.04-9.015 5.486-11.428 17.977-5.39 27.902 6.04 9.924 18.242 13.523 27.257 8.038z"
|
||||
fill="#00B95C"
|
||||
/>
|
||||
</g>
|
||||
<g filter={`url(#${filterIds[5]})`}>
|
||||
<path
|
||||
d="M67.391 42.993c10.132 0 18.346-7.91 18.346-17.666 0-9.757-8.214-17.667-18.346-17.667s-18.346 7.91-18.346 17.667c0 9.757 8.214 17.666 18.346 17.666z"
|
||||
fill="#3186FF"
|
||||
/>
|
||||
</g>
|
||||
<g filter={`url(#${filterIds[6]})`}>
|
||||
<path
|
||||
d="M-13.065 40.944c9.33 7.094 22.959 4.869 30.442-4.972 7.483-9.84 5.987-23.569-3.343-30.663C4.704-1.786-8.924.439-16.408 10.28c-7.483 9.84-5.986 23.57 3.343 30.664z"
|
||||
fill="#FBBC04"
|
||||
/>
|
||||
</g>
|
||||
<g filter={`url(#${filterIds[7]})`}>
|
||||
<path
|
||||
d="M34.74 51.43c11.135 7.656 25.896 5.524 32.968-4.764 7.073-10.287 3.779-24.832-7.357-32.488C49.215 6.52 34.455 8.654 27.382 18.94c-7.072 10.288-3.779 24.833 7.357 32.49z"
|
||||
fill="#3186FF"
|
||||
/>
|
||||
</g>
|
||||
<g filter={`url(#${filterIds[8]})`}>
|
||||
<path
|
||||
d="M54.984-2.336c2.833 3.852-.808 11.34-8.131 16.727-7.324 5.387-15.557 6.631-18.39 2.78-2.833-3.853.807-11.342 8.13-16.728 7.324-5.387 15.558-6.631 18.39-2.78z"
|
||||
fill="#749BFF"
|
||||
/>
|
||||
</g>
|
||||
<g filter={`url(#${filterIds[9]})`}>
|
||||
<path
|
||||
d="M31.727 16.104C43.053 5.598 46.94-8.626 40.41-15.666c-6.53-7.04-21.006-4.232-32.332 6.274s-15.214 24.73-8.683 31.77c6.53 7.04 21.006 4.232 32.332-6.274z"
|
||||
fill="#FC413D"
|
||||
/>
|
||||
</g>
|
||||
<g filter={`url(#${filterIds[10]})`}>
|
||||
<path
|
||||
d="M8.51 53.838c6.732 4.818 14.46 5.55 17.262 1.636 2.802-3.915-.384-10.994-7.116-15.812-6.731-4.818-14.46-5.55-17.261-1.636-2.802 3.915.383 10.994 7.115 15.812z"
|
||||
fill="#FFEE48"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id={filterIds[0]}
|
||||
x="-19.824"
|
||||
y="13.152"
|
||||
width="39.274"
|
||||
height="43.217"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="2.46" result="effect1_foregroundBlur_2001_67" />
|
||||
</filter>
|
||||
<filter
|
||||
id={filterIds[1]}
|
||||
x="-15.001"
|
||||
y="-40.257"
|
||||
width="84.868"
|
||||
height="85.688"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="11.891" result="effect1_foregroundBlur_2001_67" />
|
||||
</filter>
|
||||
<filter
|
||||
id={filterIds[2]}
|
||||
x="-20.776"
|
||||
y="11.927"
|
||||
width="79.454"
|
||||
height="90.916"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="10.109" result="effect1_foregroundBlur_2001_67" />
|
||||
</filter>
|
||||
<filter
|
||||
id={filterIds[3]}
|
||||
x="-20.776"
|
||||
y="11.927"
|
||||
width="79.454"
|
||||
height="90.916"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="10.109" result="effect1_foregroundBlur_2001_67" />
|
||||
</filter>
|
||||
<filter
|
||||
id={filterIds[4]}
|
||||
x="-19.845"
|
||||
y="15.459"
|
||||
width="79.731"
|
||||
height="81.505"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="10.109" result="effect1_foregroundBlur_2001_67" />
|
||||
</filter>
|
||||
<filter
|
||||
id={filterIds[5]}
|
||||
x="29.832"
|
||||
y="-11.552"
|
||||
width="75.117"
|
||||
height="73.758"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="9.606" result="effect1_foregroundBlur_2001_67" />
|
||||
</filter>
|
||||
<filter
|
||||
id={filterIds[6]}
|
||||
x="-38.583"
|
||||
y="-16.253"
|
||||
width="78.135"
|
||||
height="78.758"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="8.706" result="effect1_foregroundBlur_2001_67" />
|
||||
</filter>
|
||||
<filter
|
||||
id={filterIds[7]}
|
||||
x="8.107"
|
||||
y="-5.966"
|
||||
width="78.877"
|
||||
height="77.539"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="7.775" result="effect1_foregroundBlur_2001_67" />
|
||||
</filter>
|
||||
<filter
|
||||
id={filterIds[8]}
|
||||
x="13.587"
|
||||
y="-18.488"
|
||||
width="56.272"
|
||||
height="51.81"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="6.957" result="effect1_foregroundBlur_2001_67" />
|
||||
</filter>
|
||||
<filter
|
||||
id={filterIds[9]}
|
||||
x="-15.526"
|
||||
y="-31.297"
|
||||
width="70.856"
|
||||
height="69.306"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="5.876" result="effect1_foregroundBlur_2001_67" />
|
||||
</filter>
|
||||
<filter
|
||||
id={filterIds[10]}
|
||||
x="-14.168"
|
||||
y="20.964"
|
||||
width="55.501"
|
||||
height="51.571"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="7.273" result="effect1_foregroundBlur_2001_67" />
|
||||
</filter>
|
||||
<linearGradient id={gradientId} x1="18.447" x2="52.153" y1="43.42" y2="15.004" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#4893FC" />
|
||||
<stop offset=".27" stopColor="#4893FC" />
|
||||
<stop offset=".777" stopColor="#969DFF" />
|
||||
<stop offset="1" stopColor="#BD99FE" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<img src="/icons/gemini-ai-icon.svg" alt="Gemini" className={className} />
|
||||
);
|
||||
};
|
||||
|
||||
export default GeminiLogo;
|
||||
export default GeminiLogo;
|
||||
@@ -594,7 +594,16 @@ export function useSidebarController({
|
||||
|
||||
return sortedProjects.reduce<Project[]>((acc, project) => {
|
||||
const sessions = (project.sessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
|
||||
const runningCount = sessions.length;
|
||||
const cursorSessions = (project.cursorSessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
|
||||
const codexSessions = (project.codexSessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
|
||||
const geminiSessions = (project.geminiSessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
|
||||
const opencodeSessions = (project.opencodeSessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
|
||||
const runningCount =
|
||||
sessions.length
|
||||
+ cursorSessions.length
|
||||
+ codexSessions.length
|
||||
+ geminiSessions.length
|
||||
+ opencodeSessions.length;
|
||||
|
||||
if (runningCount === 0) {
|
||||
return acc;
|
||||
@@ -603,6 +612,10 @@ export function useSidebarController({
|
||||
acc.push({
|
||||
...project,
|
||||
sessions,
|
||||
cursorSessions,
|
||||
codexSessions,
|
||||
geminiSessions,
|
||||
opencodeSessions,
|
||||
sessionMeta: {
|
||||
...project.sessionMeta,
|
||||
total: runningCount,
|
||||
|
||||
@@ -61,6 +61,10 @@ export type SidebarProps = {
|
||||
};
|
||||
|
||||
export type SessionViewModel = {
|
||||
isCursorSession: boolean;
|
||||
isCodexSession: boolean;
|
||||
isGeminiSession: boolean;
|
||||
isOpenCodeSession: boolean;
|
||||
isActive: boolean;
|
||||
sessionName: string;
|
||||
sessionTime: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import type { LLMProvider, Project, ProjectSession } from '../../../types/app';
|
||||
import type { Project } from '../../../types/app';
|
||||
import type { ProjectSortOrder, SettingsProject, SessionViewModel, SessionWithProvider } from '../types/types';
|
||||
|
||||
export const readProjectSortOrder = (): ProjectSortOrder => {
|
||||
@@ -61,13 +61,6 @@ const getUpdatedTimestamp = (session: SessionWithProvider): string => {
|
||||
return String(session.lastActivity || '');
|
||||
};
|
||||
|
||||
const getSessionProvider = (session: ProjectSession): LLMProvider => {
|
||||
const provider = session.__provider ?? session.provider;
|
||||
return typeof provider === 'string' && provider.trim()
|
||||
? provider as LLMProvider
|
||||
: 'claude';
|
||||
};
|
||||
|
||||
export const getSessionDate = (session: SessionWithProvider): Date => {
|
||||
return new Date(getUpdatedTimestamp(session) || getCreatedTimestamp(session) || 0);
|
||||
};
|
||||
@@ -89,6 +82,10 @@ export const createSessionViewModel = (
|
||||
const diffInMinutes = Math.floor((currentTime.getTime() - sessionDate.getTime()) / (1000 * 60));
|
||||
|
||||
return {
|
||||
isCursorSession: session.__provider === 'cursor',
|
||||
isCodexSession: session.__provider === 'codex',
|
||||
isGeminiSession: session.__provider === 'gemini',
|
||||
isOpenCodeSession: session.__provider === 'opencode',
|
||||
isActive: diffInMinutes < 10,
|
||||
sessionName: getSessionName(session, t),
|
||||
sessionTime: getSessionTime(session),
|
||||
@@ -97,10 +94,32 @@ export const createSessionViewModel = (
|
||||
};
|
||||
|
||||
export const getAllSessions = (project: Project): SessionWithProvider[] => {
|
||||
return (project.sessions || []).map((session) => ({
|
||||
const claudeSessions = [...(project.sessions || [])].map((session) => ({
|
||||
...session,
|
||||
__provider: getSessionProvider(session),
|
||||
})).sort(
|
||||
__provider: 'claude' as const,
|
||||
}));
|
||||
|
||||
const cursorSessions = (project.cursorSessions || []).map((session) => ({
|
||||
...session,
|
||||
__provider: 'cursor' as const,
|
||||
}));
|
||||
|
||||
const codexSessions = (project.codexSessions || []).map((session) => ({
|
||||
...session,
|
||||
__provider: 'codex' as const,
|
||||
}));
|
||||
|
||||
const geminiSessions = (project.geminiSessions || []).map((session) => ({
|
||||
...session,
|
||||
__provider: 'gemini' as const,
|
||||
}));
|
||||
|
||||
const opencodeSessions = (project.opencodeSessions || []).map((session) => ({
|
||||
...session,
|
||||
__provider: 'opencode' as const,
|
||||
}));
|
||||
|
||||
return [...claudeSessions, ...cursorSessions, ...codexSessions, ...geminiSessions, ...opencodeSessions].sort(
|
||||
(a, b) => getSessionDate(b).getTime() - getSessionDate(a).getTime(),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -82,7 +82,6 @@ export default function SidebarSessionItem({
|
||||
const isEditing = editingSession === session.id;
|
||||
const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime);
|
||||
const editingContainerRef = useRef<HTMLDivElement>(null);
|
||||
const showRecentIndicator = !isProcessing && sessionView.isActive;
|
||||
|
||||
// The rename panel sits inside a group-hover opacity wrapper, so leaving the row
|
||||
// would visually hide it. While editing, dismiss only when the user clicks outside
|
||||
@@ -120,7 +119,7 @@ export default function SidebarSessionItem({
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
{showRecentIndicator && (
|
||||
{!isProcessing && sessionView.isActive && (
|
||||
<div className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform">
|
||||
<Tooltip content={t('tooltips.activeSessionIndicator')} position="right">
|
||||
<div
|
||||
@@ -157,15 +156,13 @@ export default function SidebarSessionItem({
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||
{isProcessing ? (
|
||||
<span className="ml-auto flex-shrink-0">
|
||||
<Tooltip content={t('tooltips.processingSessionIndicator', 'Processing session')} position="top">
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<Tooltip content={t('tooltips.processingSessionIndicator', 'Processing session')} position="top">
|
||||
<span className="ml-auto flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground transition-opacity duration-200 group-hover:opacity-0">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : compactSessionAge && (
|
||||
<span className="ml-auto flex-shrink-0 text-[11px] text-muted-foreground">{compactSessionAge}</span>
|
||||
)}
|
||||
@@ -179,7 +176,7 @@ export default function SidebarSessionItem({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isProcessing && (
|
||||
{!sessionView.isCursorSession && (
|
||||
<button
|
||||
className="ml-1 flex h-5 w-5 items-center justify-center rounded-md bg-red-50 opacity-70 transition-transform active:scale-95 dark:bg-red-900/20"
|
||||
onClick={(event) => {
|
||||
@@ -198,42 +195,18 @@ export default function SidebarSessionItem({
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'h-auto w-full justify-start rounded-md border bg-card p-2 text-left font-normal transition-all duration-150',
|
||||
isSelected ? 'border-primary/20 bg-primary/5' : 'border-border/30',
|
||||
!isSelected && isProcessing
|
||||
? 'border-border/60 bg-muted/20 hover:bg-muted/25'
|
||||
: !isSelected && sessionView.isActive
|
||||
? 'border-green-500/30 bg-green-50/5 hover:bg-green-50/10 dark:bg-green-900/5 dark:hover:bg-green-900/10'
|
||||
: 'hover:bg-accent/50',
|
||||
'w-full justify-start p-2 h-auto font-normal text-left hover:bg-accent/50 transition-colors duration-200',
|
||||
isSelected && 'bg-accent text-accent-foreground',
|
||||
!isSelected && isProcessing && 'bg-muted/20 hover:bg-accent/50',
|
||||
)}
|
||||
onClick={() => onSessionSelect(session, project.projectId)}
|
||||
>
|
||||
<div className="flex w-full min-w-0 items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md',
|
||||
isSelected ? 'bg-primary/10' : 'bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<SessionProviderLogo provider={session.__provider} className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="flex w-full min-w-0 items-start gap-2">
|
||||
<SessionProviderLogo provider={session.__provider} className="mt-0.5 h-3 w-3 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||
{isProcessing ? (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto flex-shrink-0 transition-opacity duration-200',
|
||||
isEditing ? 'opacity-0' : 'group-hover:opacity-0',
|
||||
)}
|
||||
>
|
||||
<Tooltip content={t('tooltips.processingSessionIndicator', 'Processing session')} position="top">
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
) : compactSessionAge && (
|
||||
<div className={cn('flex items-center gap-2', isProcessing && 'pr-7')}>
|
||||
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||
{!isProcessing && compactSessionAge && (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto flex-shrink-0 text-[11px] text-muted-foreground transition-opacity duration-200',
|
||||
@@ -251,6 +224,19 @@ export default function SidebarSessionItem({
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{isProcessing && (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={t('tooltips.processingSessionIndicator', 'Processing session')}
|
||||
className={cn(
|
||||
'pointer-events-none absolute right-2 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-opacity duration-200',
|
||||
isEditing ? 'opacity-0' : 'group-hover:opacity-0',
|
||||
)}
|
||||
>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={editingContainerRef}
|
||||
className={cn(
|
||||
@@ -309,7 +295,7 @@ export default function SidebarSessionItem({
|
||||
>
|
||||
<Edit2 className="h-3 w-3 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
{!isProcessing && (
|
||||
{!sessionView.isCursorSession && (
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40"
|
||||
onClick={(event) => {
|
||||
|
||||
@@ -29,7 +29,6 @@ type UseProjectsStateArgs = {
|
||||
*/
|
||||
type SessionUpsertedEvent = ServerEvent & {
|
||||
sessionId: string;
|
||||
providerSessionId?: string | null;
|
||||
provider: LLMProvider;
|
||||
session: ProjectSession;
|
||||
project: {
|
||||
@@ -52,36 +51,12 @@ type RegisterOptimisticSessionArgs = {
|
||||
summary?: string | null;
|
||||
};
|
||||
|
||||
type ProjectSessionPage = Pick<Project, 'sessions' | 'sessionMeta'>;
|
||||
|
||||
const DEFAULT_PROVIDER: LLMProvider = 'claude';
|
||||
|
||||
const serialize = (value: unknown) => JSON.stringify(value ?? null);
|
||||
|
||||
const readSelectedProvider = (): LLMProvider => {
|
||||
try {
|
||||
const storedProvider = localStorage.getItem('selected-provider');
|
||||
return storedProvider ? storedProvider as LLMProvider : DEFAULT_PROVIDER;
|
||||
} catch {
|
||||
return DEFAULT_PROVIDER;
|
||||
}
|
||||
};
|
||||
|
||||
const getSessionProvider = (session: ProjectSession): LLMProvider => {
|
||||
const provider = session.__provider ?? session.provider;
|
||||
return typeof provider === 'string' && provider.trim()
|
||||
? provider as LLMProvider
|
||||
: DEFAULT_PROVIDER;
|
||||
};
|
||||
|
||||
const normalizeSessionProvider = (session: ProjectSession): ProjectSession => ({
|
||||
...session,
|
||||
__provider: getSessionProvider(session),
|
||||
});
|
||||
|
||||
const projectsHaveChanges = (
|
||||
prevProjects: Project[],
|
||||
nextProjects: Project[],
|
||||
includeExternalSessions: boolean,
|
||||
): boolean => {
|
||||
if (prevProjects.length !== nextProjects.length) {
|
||||
return true;
|
||||
@@ -93,14 +68,28 @@ const projectsHaveChanges = (
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
const baseChanged =
|
||||
nextProject.projectId !== prevProject.projectId ||
|
||||
nextProject.displayName !== prevProject.displayName ||
|
||||
nextProject.fullPath !== prevProject.fullPath ||
|
||||
Boolean(nextProject.isStarred) !== Boolean(prevProject.isStarred) ||
|
||||
serialize(nextProject.sessionMeta) !== serialize(prevProject.sessionMeta) ||
|
||||
serialize(nextProject.sessions) !== serialize(prevProject.sessions) ||
|
||||
serialize(nextProject.taskmaster) !== serialize(prevProject.taskmaster)
|
||||
serialize(nextProject.taskmaster) !== serialize(prevProject.taskmaster);
|
||||
|
||||
if (baseChanged) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!includeExternalSessions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
serialize(nextProject.cursorSessions) !== serialize(prevProject.cursorSessions) ||
|
||||
serialize(nextProject.codexSessions) !== serialize(prevProject.codexSessions) ||
|
||||
serialize(nextProject.geminiSessions) !== serialize(prevProject.geminiSessions) ||
|
||||
serialize(nextProject.opencodeSessions) !== serialize(prevProject.opencodeSessions)
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -132,7 +121,13 @@ const mergeTaskMasterCache = (nextProjects: Project[], previousProjects: Project
|
||||
};
|
||||
|
||||
const getProjectSessions = (project: Project): ProjectSession[] => {
|
||||
return project.sessions ?? [];
|
||||
return [
|
||||
...(project.sessions ?? []),
|
||||
...(project.codexSessions ?? []),
|
||||
...(project.cursorSessions ?? []),
|
||||
...(project.geminiSessions ?? []),
|
||||
...(project.opencodeSessions ?? []),
|
||||
];
|
||||
};
|
||||
|
||||
const countLoadedProjectSessions = (project: Project): number => getProjectSessions(project).length;
|
||||
@@ -176,6 +171,10 @@ const mergeExpandedSessionPages = (previousProjects: Project[], incomingProjects
|
||||
const mergedProject: Project = {
|
||||
...incomingProject,
|
||||
sessions: mergeSessionProviderLists(incomingProject.sessions ?? [], previousProject.sessions ?? []),
|
||||
cursorSessions: mergeSessionProviderLists(incomingProject.cursorSessions ?? [], previousProject.cursorSessions ?? []),
|
||||
codexSessions: mergeSessionProviderLists(incomingProject.codexSessions ?? [], previousProject.codexSessions ?? []),
|
||||
geminiSessions: mergeSessionProviderLists(incomingProject.geminiSessions ?? [], previousProject.geminiSessions ?? []),
|
||||
opencodeSessions: mergeSessionProviderLists(incomingProject.opencodeSessions ?? [], previousProject.opencodeSessions ?? []),
|
||||
};
|
||||
|
||||
const totalSessions = Number(incomingProject.sessionMeta?.total ?? previousLoadedCount);
|
||||
@@ -191,11 +190,15 @@ const mergeExpandedSessionPages = (previousProjects: Project[], incomingProjects
|
||||
|
||||
const mergeProjectSessionPage = (
|
||||
existingProject: Project,
|
||||
sessionsPage: ProjectSessionPage,
|
||||
sessionsPage: Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' | 'sessionMeta'>,
|
||||
): Project => {
|
||||
const mergedProject: Project = {
|
||||
...existingProject,
|
||||
sessions: mergeSessionProviderLists(existingProject.sessions ?? [], sessionsPage.sessions ?? []),
|
||||
cursorSessions: mergeSessionProviderLists(existingProject.cursorSessions ?? [], sessionsPage.cursorSessions ?? []),
|
||||
codexSessions: mergeSessionProviderLists(existingProject.codexSessions ?? [], sessionsPage.codexSessions ?? []),
|
||||
geminiSessions: mergeSessionProviderLists(existingProject.geminiSessions ?? [], sessionsPage.geminiSessions ?? []),
|
||||
opencodeSessions: mergeSessionProviderLists(existingProject.opencodeSessions ?? [], sessionsPage.opencodeSessions ?? []),
|
||||
};
|
||||
|
||||
const totalSessions = Number(sessionsPage.sessionMeta?.total ?? existingProject.sessionMeta?.total ?? 0);
|
||||
@@ -209,77 +212,48 @@ const mergeProjectSessionPage = (
|
||||
return mergedProject;
|
||||
};
|
||||
|
||||
const getSessionAliasIds = (event: SessionUpsertedEvent): Set<string> => {
|
||||
const ids = new Set<string>();
|
||||
const add = (value: unknown) => {
|
||||
if (typeof value !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
ids.add(trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
add(event.sessionId);
|
||||
add(event.providerSessionId);
|
||||
add(event.session?.id);
|
||||
|
||||
return ids;
|
||||
/**
|
||||
* Resolves which provider bucket on a `Project` holds sessions for a provider.
|
||||
* The legacy payload keeps Claude sessions in `sessions` and the other
|
||||
* providers in their own arrays.
|
||||
*/
|
||||
const providerBucketKey = (
|
||||
provider: LLMProvider,
|
||||
): 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' => {
|
||||
if (provider === 'cursor') return 'cursorSessions';
|
||||
if (provider === 'codex') return 'codexSessions';
|
||||
if (provider === 'gemini') return 'geminiSessions';
|
||||
if (provider === 'opencode') return 'opencodeSessions';
|
||||
return 'sessions';
|
||||
};
|
||||
|
||||
/**
|
||||
* Upserts one session into a project's normalized session list.
|
||||
* Upserts one session into the matching provider bucket of a project.
|
||||
*
|
||||
* Existing rows are updated in place (summary/lastActivity changes from the
|
||||
* watcher); new rows are prepended since the watcher only fires for sessions
|
||||
* with fresh activity. `sessionMeta.total` grows only on insert.
|
||||
*/
|
||||
const upsertSessionIntoProject = (project: Project, event: SessionUpsertedEvent): Project => {
|
||||
const sessions = project.sessions ?? [];
|
||||
const aliasIds = getSessionAliasIds(event);
|
||||
const normalizedSession: ProjectSession = {
|
||||
...event.session,
|
||||
id: event.sessionId,
|
||||
__provider: event.provider,
|
||||
};
|
||||
const existingIndex = sessions.findIndex((session) => aliasIds.has(String(session.id)));
|
||||
const bucketKey = providerBucketKey(event.provider);
|
||||
const bucket = project[bucketKey] ?? [];
|
||||
const existingIndex = bucket.findIndex((session) => session.id === event.sessionId);
|
||||
|
||||
let nextSessions: ProjectSession[];
|
||||
let inserted = false;
|
||||
let nextBucket: ProjectSession[];
|
||||
if (existingIndex >= 0) {
|
||||
let changed = false;
|
||||
nextSessions = [];
|
||||
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
if (index === existingIndex) {
|
||||
const updated = { ...session, ...normalizedSession };
|
||||
if (serialize(session) !== serialize(updated)) {
|
||||
changed = true;
|
||||
}
|
||||
nextSessions.push(updated);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (aliasIds.has(String(session.id))) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
nextSessions.push(session);
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
const existing = bucket[existingIndex];
|
||||
const updated = { ...existing, ...event.session };
|
||||
if (serialize(existing) === serialize(updated)) {
|
||||
return project;
|
||||
}
|
||||
nextBucket = [...bucket];
|
||||
nextBucket[existingIndex] = updated;
|
||||
} else {
|
||||
nextSessions = [normalizedSession, ...sessions];
|
||||
inserted = true;
|
||||
nextBucket = [event.session, ...bucket];
|
||||
}
|
||||
|
||||
const next: Project = { ...project, sessions: nextSessions };
|
||||
if (inserted) {
|
||||
const next: Project = { ...project, [bucketKey]: nextBucket };
|
||||
if (existingIndex < 0) {
|
||||
const total = Number(project.sessionMeta?.total ?? 0) + 1;
|
||||
next.sessionMeta = {
|
||||
...project.sessionMeta,
|
||||
@@ -298,32 +272,14 @@ const projectFromRegistration = (project: Project): Project => ({
|
||||
displayName: project.displayName,
|
||||
isStarred: project.isStarred,
|
||||
sessions: project.sessions ?? [],
|
||||
cursorSessions: project.cursorSessions ?? [],
|
||||
codexSessions: project.codexSessions ?? [],
|
||||
geminiSessions: project.geminiSessions ?? [],
|
||||
opencodeSessions: project.opencodeSessions ?? [],
|
||||
sessionMeta: project.sessionMeta ?? { hasMore: false, total: countLoadedProjectSessions(project) },
|
||||
taskmaster: project.taskmaster,
|
||||
});
|
||||
|
||||
const removeSessionFromProject = (project: Project, sessionIdToDelete: string): Project => {
|
||||
const sessions = project.sessions ?? [];
|
||||
const nextSessions = sessions.filter((session) => session.id !== sessionIdToDelete);
|
||||
if (nextSessions.length === sessions.length) {
|
||||
return project;
|
||||
}
|
||||
|
||||
const updatedProject: Project = {
|
||||
...project,
|
||||
sessions: nextSessions,
|
||||
};
|
||||
|
||||
const totalSessions = Math.max(0, Number(project.sessionMeta?.total ?? 0) - 1);
|
||||
updatedProject.sessionMeta = {
|
||||
...project.sessionMeta,
|
||||
total: totalSessions,
|
||||
hasMore: countLoadedProjectSessions(updatedProject) < totalSessions,
|
||||
};
|
||||
|
||||
return updatedProject;
|
||||
};
|
||||
|
||||
const VALID_TABS: Set<string> = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'preview']);
|
||||
|
||||
const isValidTab = (tab: string): tab is AppTab => {
|
||||
@@ -421,7 +377,7 @@ export function useProjectsState({
|
||||
return mergedProjects;
|
||||
}
|
||||
|
||||
return projectsHaveChanges(prevProjects, mergedProjects)
|
||||
return projectsHaveChanges(prevProjects, mergedProjects, true)
|
||||
? mergedProjects
|
||||
: prevProjects;
|
||||
});
|
||||
@@ -639,6 +595,10 @@ export function useProjectsState({
|
||||
displayName: upsert.project.displayName,
|
||||
isStarred: upsert.project.isStarred,
|
||||
sessions: [],
|
||||
cursorSessions: [],
|
||||
codexSessions: [],
|
||||
geminiSessions: [],
|
||||
opencodeSessions: [],
|
||||
sessionMeta: { hasMore: false, total: 0 },
|
||||
} as Project;
|
||||
|
||||
@@ -669,40 +629,10 @@ export function useProjectsState({
|
||||
const updated = upsertSessionIntoProject(previousProject, upsert);
|
||||
return updated === previousProject ? previousProject : updated;
|
||||
});
|
||||
|
||||
const aliasedSelectedSessionId =
|
||||
typeof upsert.providerSessionId === 'string' && upsert.providerSessionId !== upsert.sessionId
|
||||
? upsert.providerSessionId
|
||||
: null;
|
||||
if (!aliasedSelectedSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSelectedSession: ProjectSession = {
|
||||
...upsert.session,
|
||||
id: upsert.sessionId,
|
||||
__provider: upsert.provider,
|
||||
__projectId: upsert.project?.projectId ?? currentSelectedSession?.__projectId,
|
||||
};
|
||||
|
||||
setSelectedSession((previousSession) => {
|
||||
if (previousSession?.id !== aliasedSelectedSessionId) {
|
||||
return previousSession;
|
||||
}
|
||||
|
||||
return {
|
||||
...previousSession,
|
||||
...normalizedSelectedSession,
|
||||
};
|
||||
});
|
||||
|
||||
if (sessionId === aliasedSelectedSessionId) {
|
||||
navigate(`/session/${upsert.sessionId}`);
|
||||
}
|
||||
};
|
||||
|
||||
return subscribe(handleEvent);
|
||||
}, [navigate, sessionId, subscribe]);
|
||||
}, [subscribe]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -720,18 +650,77 @@ export function useProjectsState({
|
||||
|
||||
// Project membership is resolved through `projectId` after the migration.
|
||||
for (const project of projects) {
|
||||
const match = project.sessions?.find((session) => session.id === sessionId);
|
||||
if (match) {
|
||||
const normalizedSession = normalizeSessionProvider(match);
|
||||
const claudeSession = project.sessions?.find((session) => session.id === sessionId);
|
||||
if (claudeSession) {
|
||||
const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
|
||||
const shouldUpdateSession =
|
||||
selectedSession?.id !== sessionId || selectedSession.__provider !== normalizedSession.__provider;
|
||||
selectedSession?.id !== sessionId || selectedSession.__provider !== 'claude';
|
||||
|
||||
if (shouldUpdateProject) {
|
||||
setSelectedProject(project);
|
||||
}
|
||||
if (shouldUpdateSession) {
|
||||
setSelectedSession(normalizedSession);
|
||||
setSelectedSession({ ...claudeSession, __provider: 'claude' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorSession = project.cursorSessions?.find((session) => session.id === sessionId);
|
||||
if (cursorSession) {
|
||||
const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
|
||||
const shouldUpdateSession =
|
||||
selectedSession?.id !== sessionId || selectedSession.__provider !== 'cursor';
|
||||
|
||||
if (shouldUpdateProject) {
|
||||
setSelectedProject(project);
|
||||
}
|
||||
if (shouldUpdateSession) {
|
||||
setSelectedSession({ ...cursorSession, __provider: 'cursor' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const codexSession = project.codexSessions?.find((session) => session.id === sessionId);
|
||||
if (codexSession) {
|
||||
const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
|
||||
const shouldUpdateSession =
|
||||
selectedSession?.id !== sessionId || selectedSession.__provider !== 'codex';
|
||||
|
||||
if (shouldUpdateProject) {
|
||||
setSelectedProject(project);
|
||||
}
|
||||
if (shouldUpdateSession) {
|
||||
setSelectedSession({ ...codexSession, __provider: 'codex' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const geminiSession = project.geminiSessions?.find((session) => session.id === sessionId);
|
||||
if (geminiSession) {
|
||||
const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
|
||||
const shouldUpdateSession =
|
||||
selectedSession?.id !== sessionId || selectedSession.__provider !== 'gemini';
|
||||
|
||||
if (shouldUpdateProject) {
|
||||
setSelectedProject(project);
|
||||
}
|
||||
if (shouldUpdateSession) {
|
||||
setSelectedSession({ ...geminiSession, __provider: 'gemini' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const opencodeSession = project.opencodeSessions?.find((session) => session.id === sessionId);
|
||||
if (opencodeSession) {
|
||||
const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
|
||||
const shouldUpdateSession =
|
||||
selectedSession?.id !== sessionId || selectedSession.__provider !== 'opencode';
|
||||
|
||||
if (shouldUpdateProject) {
|
||||
setSelectedProject(project);
|
||||
}
|
||||
if (shouldUpdateSession) {
|
||||
setSelectedSession({ ...opencodeSession, __provider: 'opencode' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -751,9 +740,27 @@ export function useProjectsState({
|
||||
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'
|
||||
: providerFromStorage === 'opencode'
|
||||
? 'opencode'
|
||||
: 'claude';
|
||||
|
||||
setSelectedSession({
|
||||
id: sessionId,
|
||||
__provider: readSelectedProvider(),
|
||||
__provider: normalizedProvider,
|
||||
__projectId: selectedProject.projectId,
|
||||
summary: '',
|
||||
});
|
||||
@@ -821,7 +828,43 @@ export function useProjectsState({
|
||||
}
|
||||
|
||||
setProjects((prevProjects) =>
|
||||
prevProjects.map((project) => removeSessionFromProject(project, sessionIdToDelete)),
|
||||
prevProjects.map((project) => {
|
||||
const sessions = project.sessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
|
||||
const cursorSessions = project.cursorSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
|
||||
const codexSessions = project.codexSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
|
||||
const geminiSessions = project.geminiSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
|
||||
const opencodeSessions = project.opencodeSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
|
||||
|
||||
const removedFromProject = (
|
||||
sessions.length !== (project.sessions?.length ?? 0)
|
||||
|| cursorSessions.length !== (project.cursorSessions?.length ?? 0)
|
||||
|| codexSessions.length !== (project.codexSessions?.length ?? 0)
|
||||
|| geminiSessions.length !== (project.geminiSessions?.length ?? 0)
|
||||
|| opencodeSessions.length !== (project.opencodeSessions?.length ?? 0)
|
||||
);
|
||||
|
||||
if (!removedFromProject) {
|
||||
return project;
|
||||
}
|
||||
|
||||
const updatedProject: Project = {
|
||||
...project,
|
||||
sessions,
|
||||
cursorSessions,
|
||||
codexSessions,
|
||||
geminiSessions,
|
||||
opencodeSessions,
|
||||
};
|
||||
|
||||
const totalSessions = Math.max(0, Number(project.sessionMeta?.total ?? 0) - 1);
|
||||
updatedProject.sessionMeta = {
|
||||
...project.sessionMeta,
|
||||
total: totalSessions,
|
||||
hasMore: countLoadedProjectSessions(updatedProject) < totalSessions,
|
||||
};
|
||||
|
||||
return updatedProject;
|
||||
}),
|
||||
);
|
||||
},
|
||||
[navigate, selectedSession?.id],
|
||||
@@ -835,7 +878,7 @@ export function useProjectsState({
|
||||
const mergedProjects = mergeExpandedSessionPages(projects, projectsWithTaskMaster);
|
||||
|
||||
setProjects((prevProjects) =>
|
||||
projectsHaveChanges(prevProjects, mergedProjects) ? mergedProjects : prevProjects,
|
||||
projectsHaveChanges(prevProjects, mergedProjects, true) ? mergedProjects : prevProjects,
|
||||
);
|
||||
|
||||
if (!selectedProject) {
|
||||
@@ -904,7 +947,7 @@ export function useProjectsState({
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const sessionsPage = (await response.json()) as ProjectSessionPage;
|
||||
const sessionsPage = (await response.json()) as Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' | 'sessionMeta'>;
|
||||
|
||||
let mergedProjectForSelection: Project | null = null;
|
||||
setProjects((previousProjects) =>
|
||||
|
||||
@@ -166,108 +166,6 @@ function hasServerEchoForLocalUser(
|
||||
});
|
||||
}
|
||||
|
||||
function compareMessagesChronologically(a: NormalizedMessage, b: NormalizedMessage): number {
|
||||
const timeA = readMessageTime(a) ?? 0;
|
||||
const timeB = readMessageTime(b) ?? 0;
|
||||
if (timeA !== timeB) {
|
||||
return timeA - timeB;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many user turns precede `message` in a chronologically merged view
|
||||
* of server + realtime rows. Used to match a realtime row to the correct turn
|
||||
* on disk when several turns share identical assistant text.
|
||||
*/
|
||||
function getUserTurnOrdinalBefore(
|
||||
message: NormalizedMessage,
|
||||
serverMessages: NormalizedMessage[],
|
||||
realtimeMessages: NormalizedMessage[],
|
||||
): number {
|
||||
const messageTime = readMessageTime(message);
|
||||
let userCount = 0;
|
||||
|
||||
for (const candidate of [...serverMessages, ...realtimeMessages].sort(compareMessagesChronologically)) {
|
||||
if (candidate.id === message.id) {
|
||||
break;
|
||||
}
|
||||
|
||||
const candidateTime = readMessageTime(candidate);
|
||||
if (
|
||||
messageTime !== null
|
||||
&& candidateTime !== null
|
||||
&& candidateTime > messageTime
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (candidate.kind === 'text' && candidate.role === 'user') {
|
||||
userCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(0, userCount - 1);
|
||||
}
|
||||
|
||||
function findServerTurnRangeByOrdinal(
|
||||
serverMessages: NormalizedMessage[],
|
||||
turnOrdinal: number,
|
||||
): { start: number; end: number } | null {
|
||||
let userCount = -1;
|
||||
let start = -1;
|
||||
|
||||
for (let index = 0; index < serverMessages.length; index++) {
|
||||
const message = serverMessages[index];
|
||||
if (message.kind === 'text' && message.role === 'user') {
|
||||
userCount++;
|
||||
if (userCount === turnOrdinal) {
|
||||
start = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (start < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let end = serverMessages.length;
|
||||
for (let index = start + 1; index < serverMessages.length; index++) {
|
||||
if (serverMessages[index].kind === 'text' && serverMessages[index].role === 'user') {
|
||||
end = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function isAssistantTextEchoedInSameTurnOnServer(
|
||||
message: NormalizedMessage,
|
||||
serverMessages: NormalizedMessage[],
|
||||
realtimeMessages: NormalizedMessage[],
|
||||
): boolean {
|
||||
const assistantText = (message.content || '').trim();
|
||||
if (!assistantText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const turnOrdinal = getUserTurnOrdinalBefore(message, serverMessages, realtimeMessages);
|
||||
const turnRange = findServerTurnRangeByOrdinal(serverMessages, turnOrdinal);
|
||||
if (!turnRange) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return serverMessages
|
||||
.slice(turnRange.start + 1, turnRange.end)
|
||||
.some((serverMessage) =>
|
||||
serverMessage.kind === 'text'
|
||||
&& serverMessage.role === 'assistant'
|
||||
&& (serverMessage.content || '').trim() === assistantText,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* After `finalizeStreaming`, the client holds a synthetic assistant `text` row
|
||||
* while the sessions API soon returns the same reply with a different id.
|
||||
@@ -305,92 +203,22 @@ function dedupeAdjacentAssistantEchoes(merged: NormalizedMessage[]): NormalizedM
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a server refresh, drop only the realtime rows the persisted transcript
|
||||
* already owns. Anything not yet on disk (common right after `complete`, while
|
||||
* JSONL indexing lags) stays in `realtimeMessages` so the chat pane never
|
||||
* flashes the empty "Continue your conversation" state.
|
||||
*/
|
||||
function pruneRealtimeSupersededByServer(
|
||||
serverMessages: NormalizedMessage[],
|
||||
realtimeMessages: NormalizedMessage[],
|
||||
): NormalizedMessage[] {
|
||||
if (realtimeMessages.length === 0) {
|
||||
return realtimeMessages;
|
||||
}
|
||||
|
||||
const serverIds = new Set(serverMessages.map((message) => message.id));
|
||||
|
||||
return realtimeMessages.filter((message) => {
|
||||
if (serverIds.has(message.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.id.startsWith('local_') && hasServerEchoForLocalUser(message, serverMessages)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.kind === 'stream_delta' || message.id === `__streaming_${message.sessionId}`) {
|
||||
if (isAssistantTextEchoedInSameTurnOnServer(message, serverMessages, realtimeMessages)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.kind === 'text' && message.role === 'assistant') {
|
||||
if (isAssistantTextEchoedInSameTurnOnServer(message, serverMessages, realtimeMessages)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.kind === 'text' && message.role === 'user') {
|
||||
return !hasServerEchoForLocalUser(message, serverMessages);
|
||||
}
|
||||
|
||||
if (message.kind === 'tool_use' && message.toolId) {
|
||||
if (serverMessages.some((serverMessage) => serverMessage.kind === 'tool_use' && serverMessage.toolId === message.toolId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function computeMerged(server: NormalizedMessage[], realtime: NormalizedMessage[]): NormalizedMessage[] {
|
||||
if (realtime.length === 0) {
|
||||
return dedupeAdjacentAssistantEchoes(server);
|
||||
}
|
||||
if (server.length === 0) {
|
||||
return dedupeAdjacentAssistantEchoes(realtime);
|
||||
}
|
||||
|
||||
const serverIds = new Set(server.map((message) => message.id));
|
||||
const extra = realtime.filter((message) => {
|
||||
if (serverIds.has(message.id)) {
|
||||
return false;
|
||||
}
|
||||
if (realtime.length === 0) return server;
|
||||
if (server.length === 0) return dedupeAdjacentAssistantEchoes(realtime);
|
||||
const serverIds = new Set(server.map(m => m.id));
|
||||
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 from the same send window, drop the realtime echo to
|
||||
// avoid duplicate bubbles without hiding repeated prompts from history.
|
||||
if (message.id.startsWith('local_')) {
|
||||
if (hasServerEchoForLocalUser(message, server)) {
|
||||
return false;
|
||||
}
|
||||
if (m.id.startsWith('local_')) {
|
||||
if (hasServerEchoForLocalUser(m, server)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (extra.length === 0) {
|
||||
return dedupeAdjacentAssistantEchoes(server);
|
||||
}
|
||||
|
||||
// Interleave by timestamp so live rows stay with their turn instead of
|
||||
// piling up at the bottom after every refresh.
|
||||
return dedupeAdjacentAssistantEchoes(
|
||||
[...server, ...extra].sort(compareMessagesChronologically),
|
||||
);
|
||||
if (extra.length === 0) return server;
|
||||
return dedupeAdjacentAssistantEchoes([...server, ...extra]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -454,6 +282,9 @@ export function useSessionStore() {
|
||||
const fetchFromServer = useCallback(async (
|
||||
sessionId: string,
|
||||
opts: {
|
||||
provider?: LLMProvider;
|
||||
projectId?: string;
|
||||
projectPath?: string;
|
||||
limit?: number | null;
|
||||
offset?: number;
|
||||
} = {},
|
||||
@@ -508,6 +339,9 @@ export function useSessionStore() {
|
||||
const fetchMore = useCallback(async (
|
||||
sessionId: string,
|
||||
opts: {
|
||||
provider?: LLMProvider;
|
||||
projectId?: string;
|
||||
projectPath?: string;
|
||||
limit?: number;
|
||||
} = {},
|
||||
) => {
|
||||
@@ -586,6 +420,11 @@ export function useSessionStore() {
|
||||
*/
|
||||
const refreshFromServer = useCallback(async (
|
||||
sessionId: string,
|
||||
_opts: {
|
||||
provider?: LLMProvider;
|
||||
projectId?: string;
|
||||
projectPath?: string;
|
||||
} = {},
|
||||
) => {
|
||||
const slot = getSlot(sessionId);
|
||||
try {
|
||||
@@ -600,13 +439,8 @@ export function useSessionStore() {
|
||||
slot.total = data.total ?? slot.serverMessages.length;
|
||||
slot.hasMore = Boolean(data.hasMore);
|
||||
slot.fetchedAt = Date.now();
|
||||
// Only drop realtime rows the server transcript now owns. A blind clear
|
||||
// here caused the chat pane to flash "Continue your conversation" after
|
||||
// `complete` while JSONL / provider_session_id indexing was still behind.
|
||||
slot.realtimeMessages = pruneRealtimeSupersededByServer(
|
||||
slot.serverMessages,
|
||||
slot.realtimeMessages,
|
||||
);
|
||||
// drop realtime messages that the server has caught up with to prevent unbounded growth.
|
||||
slot.realtimeMessages = [];
|
||||
recomputeMergedIfNeeded(slot);
|
||||
notify(sessionId);
|
||||
} catch (error) {
|
||||
|
||||
@@ -29,7 +29,6 @@ export interface ProjectSession {
|
||||
updated_at?: string;
|
||||
lastActivity?: string;
|
||||
messageCount?: number;
|
||||
provider?: LLMProvider;
|
||||
__provider?: LLMProvider;
|
||||
// Tags the session with the owning project's DB `projectId` so UI handlers
|
||||
// (session switching, sidebar focus, etc.) can match against selectedProject.
|
||||
@@ -61,6 +60,10 @@ export interface Project {
|
||||
path?: string;
|
||||
isStarred?: boolean;
|
||||
sessions?: ProjectSession[];
|
||||
cursorSessions?: ProjectSession[];
|
||||
codexSessions?: ProjectSession[];
|
||||
geminiSessions?: ProjectSession[];
|
||||
opencodeSessions?: ProjectSession[];
|
||||
sessionMeta?: ProjectSessionMeta;
|
||||
taskmaster?: ProjectTaskmasterInfo;
|
||||
[key: string]: unknown;
|
||||
|
||||
Reference in New Issue
Block a user