From 5cf47eea1bb27c280b50781f686c3e20611f9909 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Mon, 6 Jul 2026 00:17:18 +0200 Subject: [PATCH] feat(ui): group chat sessions by project in nav panel (#10212) Signed-off-by: Vincenzo Palazzo Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Douwe M Osinga --- .../src/__tests__/projectSessions.test.ts | 98 +++++++++++++++++++ .../src/components/Layout/NavigationPanel.tsx | 30 +++++- ui/desktop/src/hooks/useNavigationSessions.ts | 8 +- ui/desktop/src/utils/projectSessions.ts | 97 ++++++++++++++++++ 4 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 ui/desktop/src/__tests__/projectSessions.test.ts create mode 100644 ui/desktop/src/utils/projectSessions.ts diff --git a/ui/desktop/src/__tests__/projectSessions.test.ts b/ui/desktop/src/__tests__/projectSessions.test.ts new file mode 100644 index 0000000000..66853e7785 --- /dev/null +++ b/ui/desktop/src/__tests__/projectSessions.test.ts @@ -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 { + 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'); + }); +}); diff --git a/ui/desktop/src/components/Layout/NavigationPanel.tsx b/ui/desktop/src/components/Layout/NavigationPanel.tsx index 6c92868fe4..dc0a6129c3 100644 --- a/ui/desktop/src/components/Layout/NavigationPanel.tsx +++ b/ui/desktop/src/components/Layout/NavigationPanel.tsx @@ -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>(new Map()); @@ -195,7 +196,6 @@ export const Navigation: React.FC<{ className?: string }> = ({ className }) => { >
- {/* Nav items */}
{visibleItems.map((item) => ( = ({ className }) => { ))}
- {/* Chats section — takes remaining vertical space */}
- {/* Settings pinned to bottom */}
([]); + const recentSessionsByProject = useMemo( + () => groupSessionsByProject(recentSessions), + [recentSessions] + ); const lastSessionIdRef = useRef(null); const activeSessionId = searchParams.get('resumeSessionId') ?? undefined; @@ -203,6 +208,7 @@ export function useNavigationSessions() { return { recentSessions, + recentSessionsByProject, activeSessionId, fetchSessions, handleNavClick, diff --git a/ui/desktop/src/utils/projectSessions.ts b/ui/desktop/src/utils/projectSessions.ts new file mode 100644 index 0000000000..cf43b80336 --- /dev/null +++ b/ui/desktop/src/utils/projectSessions.ts @@ -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(); + + 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()); + + 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); +}