From d4366e3ad268a1da69776daa67623f2e88ccc28c Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Wed, 8 Apr 2026 17:13:15 +0300 Subject: [PATCH] feat: show session name and workspace path in heading --- .../ai-runtime/services/sessions.service.ts | 7 +- .../modules/ai-runtime/tests/sessions.test.ts | 20 +++ .../modules/workspaces/workspaces.routes.ts | 21 +++ .../modules/workspaces/workspaces.service.ts | 69 +++++++--- .../database/repositories/sessions.db.ts | 4 +- .../shared/layout/heading/MainHeading.tsx | 124 +++++++++++++++++- .../refactored/sidebar/data/workspacesApi.ts | 50 ++++++- .../refactored/sidebar/types/index.ts | 13 ++ 8 files changed, 284 insertions(+), 24 deletions(-) diff --git a/server/src/modules/ai-runtime/services/sessions.service.ts b/server/src/modules/ai-runtime/services/sessions.service.ts index f1b2c9e0..8a447318 100644 --- a/server/src/modules/ai-runtime/services/sessions.service.ts +++ b/server/src/modules/ai-runtime/services/sessions.service.ts @@ -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, + }; }, /** diff --git a/server/src/modules/ai-runtime/tests/sessions.test.ts b/server/src/modules/ai-runtime/tests/sessions.test.ts index 5568a489..9e23744e 100644 --- a/server/src/modules/ai-runtime/tests/sessions.test.ts +++ b/server/src/modules/ai-runtime/tests/sessions.test.ts @@ -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', }; diff --git a/server/src/modules/workspaces/workspaces.routes.ts b/server/src/modules/workspaces/workspaces.routes.ts index f198169e..3674e5fe 100644 --- a/server/src/modules/workspaces/workspaces.routes.ts +++ b/server/src/modules/workspaces/workspaces.routes.ts @@ -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 | 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) => { diff --git a/server/src/modules/workspaces/workspaces.service.ts b/server/src/modules/workspaces/workspaces.service.ts index ff934e6b..ebe876b8 100644 --- a/server/src/modules/workspaces/workspaces.service.ts +++ b/server/src/modules/workspaces/workspaces.service.ts @@ -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) { diff --git a/server/src/shared/database/repositories/sessions.db.ts b/server/src/shared/database/repositories/sessions.db.ts index 893d5cd0..d9592642 100644 --- a/server/src/shared/database/repositories/sessions.db.ts +++ b/server/src/shared/database/repositories/sessions.db.ts @@ -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 = ?` ) diff --git a/src/components/refactored/shared/layout/heading/MainHeading.tsx b/src/components/refactored/shared/layout/heading/MainHeading.tsx index 778bb98f..fbae85fb 100644 --- a/src/components/refactored/shared/layout/heading/MainHeading.tsx +++ b/src/components/refactored/shared/layout/heading/MainHeading.tsx @@ -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 = { + 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(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}
- {decodedWorkspaceId || t('mainContent.newSession')} + {subtitle}
diff --git a/src/components/refactored/sidebar/data/workspacesApi.ts b/src/components/refactored/sidebar/data/workspacesApi.ts index a613dd92..befc4f5f 100644 --- a/src/components/refactored/sidebar/data/workspacesApi.ts +++ b/src/components/refactored/sidebar/data/workspacesApi.ts @@ -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 => { return payload?.data?.workspaces || []; }; +export const getWorkspaceById = async (workspaceId: string): Promise => { + 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 => { + 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 }> => { diff --git a/src/components/refactored/sidebar/types/index.ts b/src/components/refactored/sidebar/types/index.ts index be23a0f4..a04dbd8d 100644 --- a/src/components/refactored/sidebar/types/index.ts +++ b/src/components/refactored/sidebar/types/index.ts @@ -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; +}; \ No newline at end of file