feat: show session name and workspace path in heading

This commit is contained in:
Haileyesus
2026-04-08 17:13:15 +03:00
parent e297921d31
commit d4366e3ad2
8 changed files with 284 additions and 24 deletions

View File

@@ -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,
};
},
/**

View File

@@ -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',
};

View File

@@ -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) => {

View File

@@ -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) {

View File

@@ -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 = ?`
)

View File

@@ -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>

View File

@@ -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 }> => {

View File

@@ -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;
};