From 95ea260fea99b2bb235332e6aca84532f465e05b Mon Sep 17 00:00:00 2001 From: jiang1997 Date: Mon, 14 Sep 2026 02:53:34 +0800 Subject: [PATCH] feat(desktop): name a session's location in the rail hover card A repository and its linked worktrees resolve to one Project identity, so the rail showed the worktree path as the project's preferred path and gave no way to tell which location a task actually runs in. When a project has more than one location, resolve the session's own location by matching cwd against project.locations and show it on the hover card, with the full path in the tooltip. Single-location projects stay quiet. The selector mirrors sessionProjectName so the memoized SessionRailData keeps its identity (#4109). Generated-by: Codex --- .../session-navigation-controller.test.ts | 102 ++++++++++++++++++ .../use-session-navigation-controller.ts | 37 +++++-- .../model/session-project-grouping.ts | 40 ++++++- .../ui/session-navigation-provider.tsx | 2 + apps/desktop/src/renderer/styles/sidebar.css | 1 + .../session-history-row-actions.test.tsx | 38 +++++++ packages/ui/src/session-history-list.tsx | 14 +++ packages/ui/src/session-rail-context.tsx | 2 + .../ui/stories/session-list-panel.stories.tsx | 19 ++++ packages/ui/stories/session-rail-harness.tsx | 1 + 10 files changed, 244 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts index a009138a35..8e87807923 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts @@ -191,6 +191,108 @@ describe('useSessionNavigationController', () => { assert.equal(controller().commands, first); }); + + it('names a session location only for a project that has more than one', async () => { + const { root } = installReactRenderer(); + const linked: ProjectRecord = { + id: 'linked', + name: 'Linked', + locations: [ + { path: '/repo', isWorktree: false }, + { path: '/repo-feature', isWorktree: true }, + ], + available: true, + }; + const catalog = [ + session('main', { projectId: 'linked', cwd: '/repo' }), + session('feature', { projectId: 'linked', cwd: '/repo-feature' }), + session('elsewhere', { projectId: 'linked', cwd: '/elsewhere' }), + session('single', { projectId: 'project', cwd: '/repo' }), + ]; + await act(async () => + renderController(root, { + ...input(catalog, 'main'), + projects: [linked, project], + }), + ); + + assert.equal(controller().selectors.sessionLocation(catalog[0]!), '/repo'); + assert.equal(controller().selectors.sessionLocation(catalog[1]!), '/repo-feature'); + assert.equal(controller().selectors.sessionLocation(catalog[2]!), undefined); + assert.equal(controller().selectors.sessionLocation(catalog[3]!), undefined); + }); + + it('matches Windows locations across mixed separators and case', async () => { + const { root } = installReactRenderer(); + const windows: ProjectRecord = { + id: 'windows', + name: 'Windows', + locations: [ + { path: 'C:\\Repo', isWorktree: false }, + { path: 'C:\\Repo-Feature', isWorktree: true }, + ], + available: true, + }; + const catalog = [ + session('main', { projectId: 'windows', cwd: 'c:/repo' }), + session('feature', { projectId: 'windows', cwd: 'c:/repo-feature' }), + ]; + await act(async () => + renderController(root, { ...input(catalog, 'main'), projects: [windows] }), + ); + + assert.equal(controller().selectors.sessionLocation(catalog[0]!), 'C:\\Repo'); + assert.equal(controller().selectors.sessionLocation(catalog[1]!), 'C:\\Repo-Feature'); + // The worktree mark reads the same comparison, so a forward-slash cwd must + // still find the backslash location it names. + assert.equal(controller().selectors.worktreeSessionIds.has('feature'), true); + }); + + it('matches a Windows drive root across case and separators', async () => { + const { root } = installReactRenderer(); + const windows: ProjectRecord = { + id: 'windows', + name: 'Windows', + locations: [ + { path: 'C:\\', isWorktree: false }, + { path: 'C:\\Feature', isWorktree: true }, + ], + available: true, + }; + const catalog = [session('root', { projectId: 'windows', cwd: 'c:/' })]; + await act(async () => + renderController(root, { ...input(catalog, 'root'), projects: [windows] }), + ); + + assert.equal(controller().selectors.sessionLocation(catalog[0]!), 'C:\\'); + }); + + it('does not match a Host-workspace session against local project locations', async () => { + const { root } = installReactRenderer(); + const linked: ProjectRecord = { + id: 'linked', + name: 'Linked', + locations: [ + { path: '/repo', isWorktree: false }, + { path: '/repo-feature', isWorktree: true }, + ], + available: true, + }; + const catalog = [ + session('remote', { + projectId: 'linked', + cwd: '/repo-feature', + profileId: 'remote-profile', + profileName: 'Remote Mac', + profileKind: 'remote', + }), + ]; + await act(async () => + renderController(root, { ...input(catalog, 'remote'), projects: [linked] }), + ); + + assert.equal(controller().selectors.sessionLocation(catalog[0]!), undefined); + }); }); describe('useSessionNavigationReads', () => { diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts index 0b3b9444b7..f929f813f1 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts @@ -29,7 +29,10 @@ import { } from '@maka/ui'; import { useExternalStoreSelector } from '../../../use-external-store-selector.js'; import { deriveSessionNavigationGroups } from '../model/session-navigation-groups.js'; -import { deriveWorktreeSessionIds } from '../model/session-project-grouping.js'; +import { + deriveSessionLocation, + deriveWorktreeSessionIds, +} from '../model/session-project-grouping.js'; import type { SessionRailProjection } from '../model/session-rail.js'; import { selectRailLayout, @@ -59,6 +62,7 @@ export interface SessionNavigationSelectors { groups: SessionHistoryGroup[]; worktreeSessionIds: ReadonlySet; sessionProjectName(session: SessionSummary): string | undefined; + sessionLocation(session: SessionSummary): string | undefined; sessionMeta(session: SessionSummary): string | undefined; } @@ -140,23 +144,36 @@ export function useSessionNavigationController( () => new Map(rail.sessions.map((session) => [session.id, session])), [rail.sessions], ); - const projectNameByIdentity = useMemo(() => { - const names = new Map(); + const projectByIdentity = useMemo(() => { + const projects = new Map(); for (const project of input.projects) { - names.set(project.id, project.name); - for (const alias of project.aliases ?? []) names.set(alias, project.name); + projects.set(project.id, project); + for (const alias of project.aliases ?? []) projects.set(alias, project); } - return names; + return projects; }, [input.projects]); const sessionProjectName = useCallback( (session: SessionSummary): string | undefined => deriveTitlebarProjectName({ projectName: session.projectId - ? projectNameByIdentity.get(session.projectId) + ? projectByIdentity.get(session.projectId)?.name : undefined, projectPath: session.cwd, }), - [projectNameByIdentity], + [projectByIdentity], + ); + const sessionLocation = useCallback( + (session: SessionSummary): string | undefined => { + // The same Host boundary the worktree mark honours: `projects` are the + // local ones, and a remote/environment session's cwd must not be matched + // against a local project's locations just because they look alike. + const projected: SessionNavigationSession | undefined = sessionById.get(session.id); + if (projected && runtimeHostProfileUsesHostWorkspace(projected.profileKind)) { + return undefined; + } + return deriveSessionLocation(session, projectByIdentity); + }, + [projectByIdentity, sessionById], ); const sessionMeta = useCallback( (session: SessionSummary): string | undefined => { @@ -169,8 +186,8 @@ export function useSessionNavigationController( ); const selectors = useMemo( - () => ({ groups, worktreeSessionIds, sessionProjectName, sessionMeta }), - [groups, sessionMeta, sessionProjectName, worktreeSessionIds], + () => ({ groups, worktreeSessionIds, sessionProjectName, sessionLocation, sessionMeta }), + [groups, sessionLocation, sessionMeta, sessionProjectName, worktreeSessionIds], ); const selection = useSessionSelection({ diff --git a/apps/desktop/src/renderer/features/session-navigation/model/session-project-grouping.ts b/apps/desktop/src/renderer/features/session-navigation/model/session-project-grouping.ts index 146d1bcd4f..3af3f4c06f 100644 --- a/apps/desktop/src/renderer/features/session-navigation/model/session-project-grouping.ts +++ b/apps/desktop/src/renderer/features/session-navigation/model/session-project-grouping.ts @@ -92,10 +92,46 @@ export function deriveWorktreeSessionIds( return ids; } +export function deriveSessionLocation( + session: SessionSummary, + projectsByIdentity: ReadonlyMap, +): string | undefined { + if (!session.projectId || !session.cwd) return undefined; + const project = projectsByIdentity.get(session.projectId); + if (!project || project.locations.length <= 1) return undefined; + const match = project.locations.find((location) => samePath(location.path, session.cwd!)); + return match?.path; +} + +/** + * Whether two paths name the same location. + * + * Separators are unified first: a Host may hand back either, and `/Users/a/b` + * and `\Users\a\b` are one directory on Windows, so mixed forms must match — + * the worktree mark and the location line both depend on it. Windows paths + * (drive-absolute and UNC) then fold case, matching the OS's case-insensitive + * semantics; POSIX paths stay exact. + */ function samePath(left: string, right: string): boolean { - return normalizePath(left) === normalizePath(right); + const a = normalizePath(left); + const b = normalizePath(right); + if (isWindowsPath(a) !== isWindowsPath(b)) return false; + return isWindowsPath(a) ? a.toLowerCase() === b.toLowerCase() : a === b; } function normalizePath(path: string): string { - return path.replace(/\\/g, '/').replace(/\/+$/, ''); + const unified = path.replace(/\\/g, '/'); + if (unified === '') return ''; + const trimmed = unified.replace(/\/+$/, ''); + if (trimmed.length === 0) { + // The path was all separators: a POSIX root, or the UNC root `\\`. + return unified.startsWith('//') ? '//' : '/'; + } + // A drive root keeps its separator, or `C:\` would normalize to `C:` and + // stop being recognised as a Windows path — losing case folding. + return /^[A-Za-z]:$/.test(trimmed) ? `${trimmed}/` : trimmed; +} + +function isWindowsPath(path: string): boolean { + return /^[A-Za-z]:\//.test(path) || path.startsWith('//'); } diff --git a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx index f9e0799b89..7624c5b348 100644 --- a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx +++ b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx @@ -166,6 +166,7 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps) groups: controller.layout.viewMode === 'project' ? controller.selectors.groups : undefined, groupVariant: controller.layout.viewMode, sessionProjectName: controller.selectors.sessionProjectName, + sessionLocation: controller.selectors.sessionLocation, sessionMeta: controller.selectors.sessionMeta, sessionBadge, onSelectSession: props.onSelectSession, @@ -175,6 +176,7 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps) [ controller.layout.viewMode, controller.selectors.groups, + controller.selectors.sessionLocation, controller.selectors.sessionMeta, controller.selectors.sessionProjectName, controller.selectors.worktreeSessionIds, diff --git a/apps/desktop/src/renderer/styles/sidebar.css b/apps/desktop/src/renderer/styles/sidebar.css index 031e11b3ea..cf00a6cedf 100644 --- a/apps/desktop/src/renderer/styles/sidebar.css +++ b/apps/desktop/src/renderer/styles/sidebar.css @@ -308,6 +308,7 @@ } .maka-sidebar-hover-card-path, +.maka-sidebar-hover-card-location, .maka-sidebar-hover-card-project { min-width: 0; overflow: hidden; diff --git a/packages/ui/src/__tests__/session-history-row-actions.test.tsx b/packages/ui/src/__tests__/session-history-row-actions.test.tsx index 63b74314c0..e98859331b 100644 --- a/packages/ui/src/__tests__/session-history-row-actions.test.tsx +++ b/packages/ui/src/__tests__/session-history-row-actions.test.tsx @@ -431,3 +431,41 @@ test('keeps project running totals aligned with renderer-local task streaming', assert.ok(description); assert.match(description.getAttribute('aria-label') ?? '', /1 running/); }); + +test('names the session location in the hover description only when one is provided', () => { + const describe = (markup: string): string => { + const { document } = parseHTML(markup); + const navigation = document.querySelector( + '.maka-session-row .astryx-side-nav-item', + ); + const describedBy = navigation?.getAttribute('aria-describedby'); + return (describedBy ? document.getElementById(describedBy) : null)?.getAttribute( + 'aria-label', + ) ?? ''; + }; + const located = renderToStaticMarkup( + + '/workspace/maka-agent/.worktree/sidebar'} + onSelectSession={() => undefined} + /> + , + ); + const plain = renderToStaticMarkup( + + undefined} /> + , + ); + + assert.match( + describe(located), + /\/workspace\/maka-agent\/\.worktree\/sidebar/, + 'a multi-location project names the session location', + ); + assert.doesNotMatch( + describe(plain), + /maka-agent\/\.worktree\/sidebar/, + 'a single-location project stays quiet about its location', + ); +}); diff --git a/packages/ui/src/session-history-list.tsx b/packages/ui/src/session-history-list.tsx index c256da96d9..ef0f85e8aa 100644 --- a/packages/ui/src/session-history-list.tsx +++ b/packages/ui/src/session-history-list.tsx @@ -487,6 +487,7 @@ function SessionListGroups(props: { rail.sessionProjectName?.(session) ?? deriveTitlebarProjectName({ projectPath: session.cwd }) } + location={rail.sessionLocation?.(session)} meta={rail.sessionMeta?.(session)} sessionBadge={rail.sessionBadge} onSelectSession={rail.onSelectSession} @@ -726,6 +727,7 @@ const SessionNavRow = memo(function SessionNavRow(props: { stale: boolean; worktree: boolean; projectName?: string; + location?: string; meta?: string; sessionBadge?: SessionRailData['sessionBadge']; onSelectSession(sessionId: string): void; @@ -871,6 +873,7 @@ const SessionNavRow = memo(function SessionNavRow(props: { session={props.session} status={previewStatus} projectName={props.projectName} + location={props.location} locale={locale} /> {props.actions && ( @@ -899,6 +903,7 @@ const SessionHoverCardLayer = memo(function SessionHoverCardLayer(props: { session: SessionSummary; status: string; projectName?: string; + location?: string; locale: UiLocale; }) { const copy = getSessionHoverCardCopy(props.locale); @@ -917,6 +922,7 @@ const SessionHoverCardLayer = memo(function SessionHoverCardLayer(props: { session={props.session} status={props.status} projectName={props.projectName} + location={props.location} locale={props.locale} />, ); @@ -927,6 +933,7 @@ function SessionHoverCardDescription(props: { session: SessionSummary; status: string; projectName?: string; + location?: string; locale: UiLocale; }) { const conversationCopy = getConversationCopy(props.locale); @@ -939,6 +946,7 @@ function SessionHoverCardDescription(props: { session.model, permission, props.projectName, + props.location, session.lastMessageAt ? `${copy.updated} ${formatAbsoluteTimestamp(session.lastMessageAt, props.locale)}` : undefined, @@ -953,6 +961,7 @@ function SessionHoverCardContent(props: { session: SessionSummary; status: string; projectName?: string; + location?: string; locale: UiLocale; }) { const conversationCopy = getConversationCopy(props.locale); @@ -981,6 +990,11 @@ function SessionHoverCardContent(props: { {props.projectName} ) : null} + {props.location ? ( + + {props.location} + + ) : null} {session.lastMessageAt ? ( {copy.updated}{' '} diff --git a/packages/ui/src/session-rail-context.tsx b/packages/ui/src/session-rail-context.tsx index 8af31541c9..0eda69f586 100644 --- a/packages/ui/src/session-rail-context.tsx +++ b/packages/ui/src/session-rail-context.tsx @@ -52,6 +52,8 @@ export interface SessionRailData { groupVariant: SessionViewMode; /** Human-readable project identity for a session hover card. */ sessionProjectName?(session: SessionSummary): string | undefined; + /** Session's location path, shown only when its project has multiple locations. */ + sessionLocation?(session: SessionSummary): string | undefined; sessionMeta?(session: SessionSummary): string | undefined; sessionBadge?(session: SessionSummary): ReactNode; onSelectSession(sessionId: string): void; diff --git a/packages/ui/stories/session-list-panel.stories.tsx b/packages/ui/stories/session-list-panel.stories.tsx index 2a136d5833..eb9d76f925 100644 --- a/packages/ui/stories/session-list-panel.stories.tsx +++ b/packages/ui/stories/session-list-panel.stories.tsx @@ -100,6 +100,7 @@ function panelProps(input: { groups?: SessionListPanelProps['groups']; projectActions?: SessionListPanelProps['projectActions']; worktreeSessionIds?: SessionListPanelProps['worktreeSessionIds']; + sessionLocation?: SessionListPanelProps['sessionLocation']; onSelectSession?: SessionListPanelProps['onSelectSession']; }): SessionListPanelProps { return { @@ -115,6 +116,7 @@ function panelProps(input: { ...(input.groups ? { groups: input.groups } : {}), ...(input.projectActions ? { projectActions: input.projectActions } : {}), ...(input.worktreeSessionIds ? { worktreeSessionIds: input.worktreeSessionIds } : {}), + ...(input.sessionLocation ? { sessionLocation: input.sessionLocation } : {}), onSelectSession: input.onSelectSession ?? noop, onSelect: noop, onOpenSettings: noop, @@ -580,6 +582,12 @@ export const ProjectGroups: Story = { streamingSessionIds: new Set(['proj-worktree']), viewMode: 'project', worktreeSessionIds: new Set(['proj-worktree']), + // The real selector only names a location for a project with more + // than one, so the fixture mirrors that: maka is the only one. + sessionLocation: (session) => + maka.locations.some((location) => location.path === session.cwd) + ? session.cwd + : undefined, groups: [ { id: `project:${maka.id}`, @@ -687,6 +695,17 @@ export const ProjectGroups: Story = { if (!taskHoverCard) throw new Error('task hover card is missing'); await expect(within(taskHoverCard).getByText('worktree 上的修复')).toBeVisible(); await expect(within(taskHoverCard).getByText(/glm-4\.7/)).toBeVisible(); + // The visible location line, not just its accessible description: a task in + // a multi-location project says which working directory it actually uses. + const locationLine = within(taskHoverCard).getByText( + '/workspace/maka-agent/.worktree/sidebar', + { exact: true }, + ); + await expect(locationLine).toBeVisible(); + expect(locationLine).toHaveAttribute( + 'title', + '/workspace/maka-agent/.worktree/sidebar', + ); await userEvent.hover(navigation); await waitFor(() => expect( diff --git a/packages/ui/stories/session-rail-harness.tsx b/packages/ui/stories/session-rail-harness.tsx index bdbcfb2bdd..98264302ab 100644 --- a/packages/ui/stories/session-rail-harness.tsx +++ b/packages/ui/stories/session-rail-harness.tsx @@ -60,6 +60,7 @@ export function SessionRail(props: SessionRailStoryProps) { groups: props.groups, groupVariant: props.groupVariant ?? props.viewMode ?? 'conversation', sessionProjectName: props.sessionProjectName, + sessionLocation: props.sessionLocation, sessionMeta: props.sessionMeta, onSelectSession: props.onSelectSession ?? (() => undefined), rowActions: props.rowActions,