Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions apps/desktop/src/main/__tests__/session-navigation-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -59,6 +62,7 @@ export interface SessionNavigationSelectors {
groups: SessionHistoryGroup[];
worktreeSessionIds: ReadonlySet<string>;
sessionProjectName(session: SessionSummary): string | undefined;
sessionLocation(session: SessionSummary): string | undefined;
sessionMeta(session: SessionSummary): string | undefined;
}

Expand Down Expand Up @@ -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<string, string>();
const projectByIdentity = useMemo(() => {
const projects = new Map<string, ProjectRecord>();
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 => {
Expand All @@ -169,8 +186,8 @@ export function useSessionNavigationController(
);

const selectors = useMemo<SessionNavigationSelectors>(
() => ({ groups, worktreeSessionIds, sessionProjectName, sessionMeta }),
[groups, sessionMeta, sessionProjectName, worktreeSessionIds],
() => ({ groups, worktreeSessionIds, sessionProjectName, sessionLocation, sessionMeta }),
[groups, sessionLocation, sessionMeta, sessionProjectName, worktreeSessionIds],
);

const selection = useSessionSelection({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,46 @@ export function deriveWorktreeSessionIds(
return ids;
}

export function deriveSessionLocation(
session: SessionSummary,
projectsByIdentity: ReadonlyMap<string, ProjectRecord>,
): 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('//');
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/styles/sidebar.css
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@
}

.maka-sidebar-hover-card-path,
.maka-sidebar-hover-card-location,
.maka-sidebar-hover-card-project {
min-width: 0;
overflow: hidden;
Expand Down
38 changes: 38 additions & 0 deletions packages/ui/src/__tests__/session-history-row-actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>(
'.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(
<LocaleProvider locale="en">
<Rail
sessions={[session]}
sessionLocation={() => '/workspace/maka-agent/.worktree/sidebar'}
onSelectSession={() => undefined}
/>
</LocaleProvider>,
);
const plain = renderToStaticMarkup(
<LocaleProvider locale="en">
<Rail sessions={[session]} onSelectSession={() => undefined} />
</LocaleProvider>,
);

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',
);
});
14 changes: 14 additions & 0 deletions packages/ui/src/session-history-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -871,13 +873,15 @@ const SessionNavRow = memo(function SessionNavRow(props: {
session={props.session}
status={previewStatus}
projectName={props.projectName}
location={props.location}
locale={locale}
/>
<SessionHoverCardLayer
containerRef={containerRef}
session={props.session}
status={previewStatus}
projectName={props.projectName}
location={props.location}
locale={locale}
/>
{props.actions && (
Expand All @@ -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);
Expand All @@ -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}
/>,
);
Expand All @@ -927,6 +933,7 @@ function SessionHoverCardDescription(props: {
session: SessionSummary;
status: string;
projectName?: string;
location?: string;
locale: UiLocale;
}) {
const conversationCopy = getConversationCopy(props.locale);
Expand All @@ -939,6 +946,7 @@ function SessionHoverCardDescription(props: {
session.model,
permission,
props.projectName,
props.location,
session.lastMessageAt
? `${copy.updated} ${formatAbsoluteTimestamp(session.lastMessageAt, props.locale)}`
: undefined,
Expand All @@ -953,6 +961,7 @@ function SessionHoverCardContent(props: {
session: SessionSummary;
status: string;
projectName?: string;
location?: string;
locale: UiLocale;
}) {
const conversationCopy = getConversationCopy(props.locale);
Expand Down Expand Up @@ -981,6 +990,11 @@ function SessionHoverCardContent(props: {
{props.projectName}
</span>
) : null}
{props.location ? (
<span className="maka-sidebar-hover-card-location" title={props.location}>
{props.location}
</span>
) : null}
{session.lastMessageAt ? (
<span className="maka-sidebar-hover-card-updated">
{copy.updated}{' '}
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/session-rail-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading