mirror of
https://github.com/block/goose.git
synced 2026-07-09 16:09:22 +00:00
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:
parent
3eb9f50ae5
commit
5cf47eea1b
4 changed files with 228 additions and 5 deletions
98
ui/desktop/src/__tests__/projectSessions.test.ts
Normal file
98
ui/desktop/src/__tests__/projectSessions.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
97
ui/desktop/src/utils/projectSessions.ts
Normal file
97
ui/desktop/src/utils/projectSessions.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue