feat(ui): group chat sessions by project in nav panel (#10212)

Signed-off-by: Vincenzo Palazzo <vincenzopalazzodev@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Douwe M Osinga <douwe@sidewalklabs.com>
This commit is contained in:
Vincenzo Palazzo 2026-07-06 00:17:18 +02:00 committed by GitHub
parent 3eb9f50ae5
commit 5cf47eea1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 228 additions and 5 deletions

View file

@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import { getProjectLabel, groupSessionsByProject } from '../utils/projectSessions';
import type { SessionListItem } from '../acp/sessions';
function makeSession(overrides: Partial<SessionListItem> = {}): SessionListItem {
return {
id: 'session-1',
name: 'Session',
messageCount: 1,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
workingDir: '/tmp/goose',
...overrides,
};
}
describe('groupSessionsByProject', () => {
it('groups sessions by normalized working directory', () => {
const groups = groupSessionsByProject([
makeSession({ id: 'a', workingDir: '/tmp/goose' }),
makeSession({ id: 'b', workingDir: '/tmp/goose/' }),
makeSession({ id: 'c', workingDir: ' /tmp/goose// ' }),
makeSession({ id: 'd', workingDir: '/tmp/other' }),
]);
expect(groups).toHaveLength(2);
expect(groups.find((group) => group.path === '/tmp/goose')?.sessions.map((s) => s.id)).toEqual([
'a',
'b',
'c',
]);
expect(groups.find((group) => group.path === '/tmp/other')?.sessions.map((s) => s.id)).toEqual([
'd',
]);
});
it('sorts project groups and sessions by session activity', () => {
const groups = groupSessionsByProject([
makeSession({ id: 'old', workingDir: '/tmp/old', updatedAt: '2026-01-01T00:00:00.000Z' }),
makeSession({
id: 'middle-old',
workingDir: '/tmp/middle',
updatedAt: '2026-01-02T00:00:00.000Z',
}),
makeSession({
id: 'middle-new',
workingDir: '/tmp/middle',
updatedAt: '2026-01-03T00:00:00.000Z',
}),
makeSession({
id: 'renamed',
workingDir: '/tmp/new',
updatedAt: '2026-01-04T00:00:00.000Z',
lastMessageAt: '2026-01-01T00:00:00.000Z',
}),
makeSession({
id: 'active',
workingDir: '/tmp/new',
updatedAt: '2026-01-02T00:00:00.000Z',
lastMessageAt: '2026-01-05T00:00:00.000Z',
}),
]);
expect(groups.map((group) => group.path)).toEqual(['/tmp/new', '/tmp/middle', '/tmp/old']);
expect(groups[0].sessions.map((session) => session.id)).toEqual(['active', 'renamed']);
expect(groups[1].sessions.map((session) => session.id)).toEqual(['middle-new', 'middle-old']);
});
it('handles missing working directories', () => {
const groups = groupSessionsByProject([
makeSession({ id: 'a', workingDir: '' }),
makeSession({ id: 'b', workingDir: ' ' }),
]);
expect(groups).toHaveLength(1);
expect(groups[0].path).toBe('');
expect(groups[0].label).toBe('Unknown');
expect(groups[0].sessions.map((session) => session.id)).toEqual(['a', 'b']);
});
it('disambiguates projects with the same basename', () => {
const groups = groupSessionsByProject([
makeSession({ id: 'a', workingDir: '/Users/me/work/goose' }),
makeSession({ id: 'b', workingDir: '/Users/me/forks/goose' }),
]);
expect(groups.map((group) => group.label).sort()).toEqual(['forks/goose', 'work/goose']);
});
});
describe('getProjectLabel', () => {
it('extracts readable labels from paths', () => {
expect(getProjectLabel('/Users/me/work/goose')).toBe('goose');
expect(getProjectLabel('/')).toBe('/');
expect(getProjectLabel('')).toBe('Unknown');
expect(getProjectLabel('C:\\Users\\me\\goose')).toBe('goose');
});
});

View file

@ -16,6 +16,7 @@ import { InlineEditText } from '../common/InlineEditText';
import { SessionIndicators } from '../SessionIndicators';
import { acpRenameSession, type SessionListItem } from '../../acp/sessions';
import { cn } from '../../utils';
import type { ProjectGroup } from '../../utils/projectSessions';
import { defineMessages, useIntl } from '../../i18n';
type StreamState = 'idle' | 'loading' | 'streaming' | 'error';
@ -134,7 +135,7 @@ export const Navigation: React.FC<{ className?: string }> = ({ className }) => {
const isActive = useCallback((path: string) => location.pathname === path, [location.pathname]);
const { recentSessions, activeSessionId, fetchSessions, handleNavClick, handleSessionClick } =
const { recentSessions, recentSessionsByProject, activeSessionId, fetchSessions, handleNavClick, handleSessionClick } =
useNavigationSessions();
const [sessionStatuses, setSessionStatuses] = useState<Map<string, SessionStatus>>(new Map());
@ -195,7 +196,6 @@ export const Navigation: React.FC<{ className?: string }> = ({ className }) => {
>
<div className="h-[48px] no-drag" />
{/* Nav items */}
<div className="px-2 flex flex-col gap-0.5">
{visibleItems.map((item) => (
<NavRow
@ -207,7 +207,6 @@ export const Navigation: React.FC<{ className?: string }> = ({ className }) => {
))}
</div>
{/* Chats section — takes remaining vertical space */}
<div className="flex-1 min-h-0 flex flex-col mt-3">
<button
onClick={() => setIsChatsExpanded((v) => !v)}
@ -226,6 +225,30 @@ export const Navigation: React.FC<{ className?: string }> = ({ className }) => {
<div className="px-3 py-2 text-xs text-text-secondary">
{intl.formatMessage(i18n.noChats)}
</div>
) : recentSessionsByProject.length > 1 ? (
recentSessionsByProject.map((group: ProjectGroup) => (
<React.Fragment key={group.path}>
<div
className="px-3 pt-2 pb-0.5 text-[10px] uppercase tracking-wider text-text-tertiary truncate"
title={group.path}
>
{group.label}
</div>
{group.sessions.map((session) => (
<SessionRow
key={session.id}
session={session}
active={session.id === activeSessionId}
status={sessionStatuses.get(session.id)}
onClick={() => {
clearUnread(session.id);
handleSessionClick(session.id);
}}
onRenamed={fetchSessions}
/>
))}
</React.Fragment>
))
) : (
recentSessions.map((session) => (
<SessionRow
@ -245,7 +268,6 @@ export const Navigation: React.FC<{ className?: string }> = ({ className }) => {
)}
</div>
{/* Settings pinned to bottom */}
<div className="px-2 pt-2 pb-2 border-t border-border-secondary">
<NavRow
item={SETTINGS_NAV_ITEM}

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import { useChatContext } from '../contexts/ChatContext';
import { getSessionDisplayName } from '../sessions';
@ -9,6 +9,7 @@ import {
acpListRecentSessions,
type SessionListItem,
} from '../acp/sessions';
import { groupSessionsByProject } from '../utils/projectSessions';
const MAX_RECENT_SESSIONS = 25;
@ -55,6 +56,10 @@ export function useNavigationSessions() {
const chatContext = useChatContext();
const [recentSessions, setRecentSessions] = useState<SessionListItem[]>([]);
const recentSessionsByProject = useMemo(
() => groupSessionsByProject(recentSessions),
[recentSessions]
);
const lastSessionIdRef = useRef<string | null>(null);
const activeSessionId = searchParams.get('resumeSessionId') ?? undefined;
@ -203,6 +208,7 @@ export function useNavigationSessions() {
return {
recentSessions,
recentSessionsByProject,
activeSessionId,
fetchSessions,
handleNavClick,

View file

@ -0,0 +1,97 @@
import type { SessionListItem } from '../acp/sessions';
export interface ProjectGroup {
path: string;
label: string;
sessions: SessionListItem[];
lastActivityAt: string;
}
function getSessionActivityTime(session: SessionListItem): string {
return session.lastMessageAt ?? session.updatedAt;
}
const UNKNOWN_PROJECT_LABEL = 'Unknown';
export function normalizeProjectPath(workingDir: string): string {
const normalized = workingDir.trim();
if (!normalized) {
return '';
}
const withoutTrailingSeparators = normalized.replace(/[\\/]+$/, '');
return withoutTrailingSeparators || normalized;
}
export function getProjectLabel(workingDir: string): string {
const normalized = workingDir.trim();
if (!normalized) {
return UNKNOWN_PROJECT_LABEL;
}
const withoutTrailingSeparators = normalizeProjectPath(workingDir);
if (!withoutTrailingSeparators) {
return normalized;
}
const parts = withoutTrailingSeparators.split(/[\\/]+/);
return parts[parts.length - 1] || normalized;
}
export function groupSessionsByProject(sessions: SessionListItem[]): ProjectGroup[] {
const groups = new Map<string, SessionListItem[]>();
for (const session of sessions) {
const path = normalizeProjectPath(session.workingDir);
const existing = groups.get(path);
if (existing) {
existing.push(session);
} else {
groups.set(path, [session]);
}
}
const baseGroups = Array.from(groups.entries()).map(([path, projectSessions]) => {
const sortedSessions = [...projectSessions].sort(
(a, b) =>
new Date(getSessionActivityTime(b)).getTime() -
new Date(getSessionActivityTime(a)).getTime()
);
return {
path,
label: getProjectLabel(path),
sessions: sortedSessions,
lastActivityAt: getSessionActivityTime(sortedSessions[0] ?? { updatedAt: '' } as SessionListItem),
};
});
const labelCounts = baseGroups.reduce((counts, group) => {
counts.set(group.label, (counts.get(group.label) ?? 0) + 1);
return counts;
}, new Map<string, number>());
return baseGroups
.map((group) => ({
...group,
label:
(labelCounts.get(group.label) ?? 0) > 1
? getDisambiguatedProjectLabel(group.path)
: group.label,
}))
.sort(
(a, b) => new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime()
);
}
function getDisambiguatedProjectLabel(workingDir: string): string {
const withoutTrailingSeparators = normalizeProjectPath(workingDir);
if (!withoutTrailingSeparators) {
return UNKNOWN_PROJECT_LABEL;
}
const parts = withoutTrailingSeparators.split(/[\\/]+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
}
return getProjectLabel(workingDir);
}