mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-16 01:12:46 +00:00
feat: show session name and workspace path in heading
This commit is contained in:
@@ -3,6 +3,7 @@ import fsp, { readFile } from 'node:fs/promises';
|
||||
|
||||
import { scanStateDb } from '@/shared/database/repositories/scan-state.db.js';
|
||||
import { sessionsDb } from '@/shared/database/repositories/sessions.db.js';
|
||||
import { workspaceOriginalPathsDb } from '@/shared/database/repositories/workspace-original-paths.db.js';
|
||||
import type { LLMProvider } from '@/shared/types/app.js';
|
||||
import { AppError } from '@/shared/utils/app-error.js';
|
||||
import { llmProviderRegistry } from '@/modules/ai-runtime/ai-runtime.registry.js';
|
||||
@@ -118,7 +119,11 @@ export const llmSessionsService = {
|
||||
});
|
||||
}
|
||||
|
||||
return session;
|
||||
const workspace = workspaceOriginalPathsDb.getWorkspacePath(session.workspace_path);
|
||||
return {
|
||||
...session,
|
||||
workspace_id: workspace?.workspace_id ?? null,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ import test from 'node:test';
|
||||
import { AppError } from '@/shared/utils/app-error.js';
|
||||
import { scanStateDb } from '@/shared/database/repositories/scan-state.db.js';
|
||||
import { sessionsDb } from '@/shared/database/repositories/sessions.db.js';
|
||||
import { workspaceOriginalPathsDb } from '@/shared/database/repositories/workspace-original-paths.db.js';
|
||||
import { llmProviderRegistry } from '@/modules/ai-runtime/ai-runtime.registry.js';
|
||||
import { llmSessionsService } from '@/modules/ai-runtime/services/sessions.service.js';
|
||||
import { conversationSearchService } from '@/modules/conversations/conversation-search.service.js';
|
||||
@@ -102,6 +103,7 @@ test('llmSessionsService.updateSessionCustomName validates existence before upda
|
||||
provider: 'claude',
|
||||
workspace_path: '/tmp/workspace',
|
||||
jsonl_path: null,
|
||||
custom_name: null,
|
||||
created_at: '2026-04-01T00:00:00.000Z',
|
||||
updated_at: '2026-04-01T00:00:00.000Z',
|
||||
}
|
||||
@@ -140,11 +142,22 @@ test('llmSessionsService.getIndexedSession returns DB session metadata', { concu
|
||||
provider: 'claude',
|
||||
workspace_path: '/tmp/workspace',
|
||||
jsonl_path: '/tmp/workspace/session.jsonl',
|
||||
custom_name: 'Custom Session Name',
|
||||
created_at: '2026-04-01T00:00:00.000Z',
|
||||
updated_at: '2026-04-02T00:00:00.000Z',
|
||||
}
|
||||
: null
|
||||
));
|
||||
const restoreGetWorkspacePath = patchMethod(workspaceOriginalPathsDb, 'getWorkspacePath', (workspacePath: string) => (
|
||||
workspacePath === '/tmp/workspace'
|
||||
? {
|
||||
workspace_id: 'workspace-123',
|
||||
workspace_path: workspacePath,
|
||||
custom_workspace_name: 'Workspace Custom Name',
|
||||
isStarred: 0,
|
||||
}
|
||||
: null
|
||||
));
|
||||
|
||||
try {
|
||||
const session = llmSessionsService.getIndexedSession('known-session');
|
||||
@@ -153,8 +166,10 @@ test('llmSessionsService.getIndexedSession returns DB session metadata', { concu
|
||||
provider: 'claude',
|
||||
workspace_path: '/tmp/workspace',
|
||||
jsonl_path: '/tmp/workspace/session.jsonl',
|
||||
custom_name: 'Custom Session Name',
|
||||
created_at: '2026-04-01T00:00:00.000Z',
|
||||
updated_at: '2026-04-02T00:00:00.000Z',
|
||||
workspace_id: 'workspace-123',
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
@@ -165,6 +180,7 @@ test('llmSessionsService.getIndexedSession returns DB session metadata', { concu
|
||||
error.statusCode === 404,
|
||||
);
|
||||
} finally {
|
||||
restoreGetWorkspacePath();
|
||||
restoreGetById();
|
||||
}
|
||||
});
|
||||
@@ -183,6 +199,7 @@ test('llmSessionsService.deleteSessionArtifacts validates ids and deletes disk/d
|
||||
provider: 'cursor',
|
||||
workspace_path: '/tmp/workspace',
|
||||
jsonl_path: transcriptPath,
|
||||
custom_name: null,
|
||||
created_at: '2026-04-01T00:00:00.000Z',
|
||||
updated_at: '2026-04-01T00:00:00.000Z',
|
||||
}
|
||||
@@ -233,6 +250,7 @@ test('llmSessionsService.getSessionHistory parses JSONL and Gemini JSON correctl
|
||||
provider: 'cursor',
|
||||
workspace_path: '/tmp/workspace',
|
||||
jsonl_path: jsonlPath,
|
||||
custom_name: null,
|
||||
created_at: '2026-04-01T00:00:00.000Z',
|
||||
updated_at: '2026-04-01T00:00:00.000Z',
|
||||
};
|
||||
@@ -244,6 +262,7 @@ test('llmSessionsService.getSessionHistory parses JSONL and Gemini JSON correctl
|
||||
provider: 'gemini',
|
||||
workspace_path: '/tmp/workspace',
|
||||
jsonl_path: jsonPath,
|
||||
custom_name: null,
|
||||
created_at: '2026-04-01T00:00:00.000Z',
|
||||
updated_at: '2026-04-01T00:00:00.000Z',
|
||||
};
|
||||
@@ -255,6 +274,7 @@ test('llmSessionsService.getSessionHistory parses JSONL and Gemini JSON correctl
|
||||
provider: 'claude',
|
||||
workspace_path: '/tmp/workspace',
|
||||
jsonl_path: null,
|
||||
custom_name: null,
|
||||
created_at: '2026-04-01T00:00:00.000Z',
|
||||
updated_at: '2026-04-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
@@ -29,6 +29,18 @@ const parseWorkspaceIdFromBody = (req: Request): string => {
|
||||
return workspaceId;
|
||||
};
|
||||
|
||||
const parseWorkspaceIdFromParams = (req: Request): string => {
|
||||
const workspaceId = getTrimmedString(req.params.workspaceId);
|
||||
if (!workspaceId) {
|
||||
throw new AppError('workspaceId is required.', {
|
||||
code: 'WORKSPACE_ID_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return workspaceId;
|
||||
};
|
||||
|
||||
const parseWorkspaceCustomNameFromBody = (req: Request): string | null => {
|
||||
const body = req.body as Record<string, unknown> | undefined;
|
||||
const customName = getTrimmedString(body?.workspaceCustomName);
|
||||
@@ -43,6 +55,15 @@ router.get(
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:workspaceId',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const workspaceId = parseWorkspaceIdFromParams(req);
|
||||
const workspace = workspaceService.getWorkspaceById(workspaceId);
|
||||
res.json(createApiSuccessResponse({ workspace }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/star',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
|
||||
@@ -86,6 +86,30 @@ const sortWorkspacesByLastActivity = (
|
||||
return left.workspaceDisplayName.localeCompare(right.workspaceDisplayName);
|
||||
});
|
||||
|
||||
const toWorkspaceRecord = (
|
||||
workspaceId: string,
|
||||
workspacePath: string,
|
||||
workspaceCustomName: string | null,
|
||||
isStarred: boolean,
|
||||
sessions: WorkspaceSessionRecord[],
|
||||
): WorkspaceRecord => {
|
||||
const sortedSessions = sortSessionsByLastActivity(sessions);
|
||||
const lastActivity = sortedSessions[0]?.lastActivity || null;
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
workspaceOriginalPath: workspacePath,
|
||||
workspaceCustomName,
|
||||
workspaceDisplayName:
|
||||
workspaceCustomName ||
|
||||
path.basename(workspacePath) ||
|
||||
workspacePath,
|
||||
isStarred,
|
||||
lastActivity,
|
||||
sessions: sortedSessions,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Groups indexed sessions by workspace and returns a deterministic catalog shape.
|
||||
*/
|
||||
@@ -102,23 +126,14 @@ const buildWorkspaceSessionCollection = (): WorkspaceRecord[] => {
|
||||
}
|
||||
|
||||
const workspaceRecords = workspaceRows.map((workspaceRow) => {
|
||||
const sessions = sortSessionsByLastActivity(
|
||||
sessionsByWorkspace.get(workspaceRow.workspace_path) || [],
|
||||
);
|
||||
const lastActivity = sessions[0]?.lastActivity || null;
|
||||
|
||||
return {
|
||||
workspaceId: workspaceRow.workspace_id,
|
||||
workspaceOriginalPath: workspaceRow.workspace_path,
|
||||
workspaceCustomName: workspaceRow.custom_workspace_name,
|
||||
workspaceDisplayName:
|
||||
workspaceRow.custom_workspace_name ||
|
||||
path.basename(workspaceRow.workspace_path) ||
|
||||
workspaceRow.workspace_path,
|
||||
isStarred: workspaceRow.isStarred === 1,
|
||||
lastActivity,
|
||||
const sessions = sessionsByWorkspace.get(workspaceRow.workspace_path) || [];
|
||||
return toWorkspaceRecord(
|
||||
workspaceRow.workspace_id,
|
||||
workspaceRow.workspace_path,
|
||||
workspaceRow.custom_workspace_name,
|
||||
workspaceRow.isStarred === 1,
|
||||
sessions,
|
||||
};
|
||||
);
|
||||
});
|
||||
|
||||
return sortWorkspacesByLastActivity(workspaceRecords);
|
||||
@@ -132,6 +147,28 @@ export const workspaceService = {
|
||||
return buildWorkspaceSessionCollection();
|
||||
},
|
||||
|
||||
getWorkspaceById(workspaceId: string): WorkspaceRecord {
|
||||
const workspaceRow = workspaceOriginalPathsDb.getWorkspaceById(workspaceId);
|
||||
if (!workspaceRow) {
|
||||
throw new AppError('Workspace not found.', {
|
||||
code: 'WORKSPACE_NOT_FOUND',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const sessions = sessionsDb
|
||||
.getSessionsByWorkspacePath(workspaceRow.workspace_path)
|
||||
.map(toWorkspaceSessionRecord);
|
||||
|
||||
return toWorkspaceRecord(
|
||||
workspaceRow.workspace_id,
|
||||
workspaceRow.workspace_path,
|
||||
workspaceRow.custom_workspace_name,
|
||||
workspaceRow.isStarred === 1,
|
||||
sessions,
|
||||
);
|
||||
},
|
||||
|
||||
toggleWorkspaceStar(workspaceId: string): boolean {
|
||||
const workspaceRow = workspaceOriginalPathsDb.getWorkspaceById(workspaceId);
|
||||
if (!workspaceRow) {
|
||||
|
||||
@@ -14,7 +14,7 @@ type SessionNameLookupRow = {
|
||||
|
||||
type SessionMetadataLookupRow = Pick<
|
||||
SessionsRow,
|
||||
'session_id' | 'provider' | 'workspace_path' | 'jsonl_path' | 'created_at' | 'updated_at'
|
||||
'session_id' | 'provider' | 'workspace_path' | 'jsonl_path' | 'custom_name' | 'created_at' | 'updated_at'
|
||||
>;
|
||||
|
||||
function normalizeTimestamp(value?: string): string | null {
|
||||
@@ -119,7 +119,7 @@ export const sessionsDb = {
|
||||
const db = getConnection();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, workspace_path, jsonl_path, created_at, updated_at
|
||||
`SELECT session_id, provider, workspace_path, jsonl_path, custom_name, created_at, updated_at
|
||||
FROM sessions
|
||||
WHERE session_id = ?`
|
||||
)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { AppTab } from '@/types/app';
|
||||
import type { AppTab, SessionProvider } from '@/types/app';
|
||||
import { usePlugins } from '@/contexts/PluginsContext';
|
||||
import { useDeviceSettings } from '@/hooks/useDeviceSettings';
|
||||
import { useSystemUI } from '@/components/refactored/shared/contexts/system-ui-context/useSystemUI';
|
||||
import { MainHeadingTabSwitcher } from '@/components/refactored/shared/layout/heading/MainHeadingTabSwitcher';
|
||||
import { getSessionById, getWorkspaceById } from '@/components/refactored/sidebar/data/workspacesApi';
|
||||
|
||||
type MainHeadingRouteParams = {
|
||||
workspaceId?: string;
|
||||
@@ -60,6 +61,20 @@ const getTabTitle = (tab: AppTab, pluginDisplayName: string | undefined, t: (key
|
||||
return t('tabs.chat');
|
||||
};
|
||||
|
||||
const getStoredModelForProvider = (provider: SessionProvider): string => {
|
||||
const storageKeyByProvider: Record<SessionProvider, string> = {
|
||||
claude: 'claude-model',
|
||||
cursor: 'cursor-model',
|
||||
codex: 'codex-model',
|
||||
gemini: 'gemini-model',
|
||||
};
|
||||
|
||||
const model = localStorage.getItem(storageKeyByProvider[provider]);
|
||||
return typeof model === 'string' ? model.trim() : '';
|
||||
};
|
||||
|
||||
const getTrimmedOrEmpty = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
|
||||
|
||||
export function MainHeading() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation(['common', 'sidebar']);
|
||||
@@ -72,6 +87,9 @@ export function MainHeading() {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
const [resolvedWorkspacePath, setResolvedWorkspacePath] = useState('');
|
||||
const [resolvedSessionTitle, setResolvedSessionTitle] = useState('');
|
||||
const [resolvedSessionWorkspacePath, setResolvedSessionWorkspacePath] = useState('');
|
||||
|
||||
const decodedWorkspaceId = useMemo(() => decodeValue(workspaceId), [workspaceId]);
|
||||
const decodedSessionId = useMemo(() => decodeValue(sessionId), [sessionId]);
|
||||
@@ -96,9 +114,100 @@ export function MainHeading() {
|
||||
[activeTab, plugins],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!decodedWorkspaceId) {
|
||||
setResolvedWorkspacePath('');
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
|
||||
const loadWorkspace = async () => {
|
||||
try {
|
||||
const workspace = await getWorkspaceById(decodedWorkspaceId);
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pathValue =
|
||||
getTrimmedOrEmpty(workspace.workspaceOriginalPath) ||
|
||||
decodedWorkspaceId;
|
||||
setResolvedWorkspacePath(pathValue);
|
||||
} catch {
|
||||
if (!disposed) {
|
||||
setResolvedWorkspacePath(decodedWorkspaceId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadWorkspace();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [decodedWorkspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!decodedSessionId) {
|
||||
setResolvedSessionTitle('');
|
||||
setResolvedSessionWorkspacePath('');
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
|
||||
const loadSession = async () => {
|
||||
try {
|
||||
const session = await getSessionById(decodedSessionId);
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const customName = getTrimmedOrEmpty(session.custom_name);
|
||||
const modelName = getStoredModelForProvider(session.provider);
|
||||
setResolvedSessionTitle(customName || modelName || decodedSessionId);
|
||||
setResolvedSessionWorkspacePath(getTrimmedOrEmpty(session.workspace_path));
|
||||
|
||||
const workspaceIdFromSession = getTrimmedOrEmpty(session.workspace_id);
|
||||
if (!workspaceIdFromSession) {
|
||||
if (!decodedWorkspaceId) {
|
||||
setResolvedWorkspacePath(getTrimmedOrEmpty(session.workspace_path));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const workspace = await getWorkspaceById(workspaceIdFromSession);
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspacePath =
|
||||
getTrimmedOrEmpty(workspace.workspaceOriginalPath) ||
|
||||
workspaceIdFromSession;
|
||||
setResolvedWorkspacePath(workspacePath);
|
||||
} catch {
|
||||
if (!disposed && !decodedWorkspaceId) {
|
||||
setResolvedWorkspacePath(getTrimmedOrEmpty(session.workspace_path));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!disposed) {
|
||||
setResolvedSessionTitle(decodedSessionId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadSession();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [decodedSessionId, decodedWorkspaceId]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (activeTab === 'chat' && decodedSessionId) {
|
||||
return decodedSessionId;
|
||||
return resolvedSessionTitle || decodedSessionId;
|
||||
}
|
||||
|
||||
if (activeTab === 'chat') {
|
||||
@@ -106,7 +215,14 @@ export function MainHeading() {
|
||||
}
|
||||
|
||||
return getTabTitle(activeTab, pluginDisplayName, t);
|
||||
}, [activeTab, decodedSessionId, pluginDisplayName, t]);
|
||||
}, [activeTab, decodedSessionId, pluginDisplayName, resolvedSessionTitle, t]);
|
||||
|
||||
const subtitle = useMemo(() => (
|
||||
resolvedSessionWorkspacePath ||
|
||||
resolvedWorkspacePath ||
|
||||
decodedWorkspaceId ||
|
||||
t('mainContent.newSession')
|
||||
), [decodedWorkspaceId, resolvedSessionWorkspacePath, resolvedWorkspacePath, t]);
|
||||
|
||||
const updateScrollState = useCallback(() => {
|
||||
const element = scrollRef.current;
|
||||
@@ -180,7 +296,7 @@ export function MainHeading() {
|
||||
{title}
|
||||
</h2>
|
||||
<div className="truncate text-[11px] leading-tight text-muted-foreground">
|
||||
{decodedWorkspaceId || t('mainContent.newSession')}
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WorkspaceRecord } from '@/components/refactored/sidebar/types';
|
||||
import type { SessionMetadataRecord, WorkspaceRecord } from '@/components/refactored/sidebar/types';
|
||||
import { authenticatedFetch } from '@/utils/api';
|
||||
|
||||
const SIDEBAR_ENDPOINTS = {
|
||||
@@ -56,6 +56,54 @@ export const getWorkspaceSessions = async (): Promise<WorkspaceRecord[]> => {
|
||||
return payload?.data?.workspaces || [];
|
||||
};
|
||||
|
||||
export const getWorkspaceById = async (workspaceId: string): Promise<WorkspaceRecord> => {
|
||||
const response = await authenticatedFetch(
|
||||
`${SIDEBAR_ENDPOINTS.getWorkspaceSessions}/${encodeURIComponent(workspaceId)}`,
|
||||
);
|
||||
const payload = await parseJsonSafely<{
|
||||
success?: boolean;
|
||||
data?: { workspace?: WorkspaceRecord };
|
||||
error?: { message?: string };
|
||||
}>(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
payload?.error?.message ||
|
||||
getErrorMessage('Failed to fetch workspace', payload),
|
||||
);
|
||||
}
|
||||
|
||||
const workspace = payload?.data?.workspace;
|
||||
if (!workspace) {
|
||||
throw new Error('Workspace not found in response payload');
|
||||
}
|
||||
|
||||
return workspace;
|
||||
};
|
||||
|
||||
export const getSessionById = async (sessionId: string): Promise<SessionMetadataRecord> => {
|
||||
const response = await authenticatedFetch(`/api/llm/sessions/${encodeURIComponent(sessionId)}`);
|
||||
const payload = await parseJsonSafely<{
|
||||
success?: boolean;
|
||||
data?: { session?: SessionMetadataRecord };
|
||||
error?: { message?: string };
|
||||
}>(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
payload?.error?.message ||
|
||||
getErrorMessage('Failed to fetch session', payload),
|
||||
);
|
||||
}
|
||||
|
||||
const session = payload?.data?.session;
|
||||
if (!session) {
|
||||
throw new Error('Session not found in response payload');
|
||||
}
|
||||
|
||||
return session;
|
||||
};
|
||||
|
||||
export const updateWorkspaceStar = async (
|
||||
workspaceId: string,
|
||||
): Promise<{ workspaceId: string; isStarred: boolean }> => {
|
||||
|
||||
@@ -41,3 +41,16 @@ export type WorkspaceGroups = {
|
||||
starred: WorkspaceRecord[];
|
||||
unstarred: WorkspaceRecord[];
|
||||
};
|
||||
|
||||
|
||||
// -------- SESSION TYPES --------
|
||||
export type SessionMetadataRecord = {
|
||||
session_id: string;
|
||||
provider: SessionProvider;
|
||||
workspace_path: string;
|
||||
workspace_id: string | null;
|
||||
custom_name: string | null;
|
||||
jsonl_path: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
Reference in New Issue
Block a user