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 { SessionIndicators } from '../SessionIndicators';
|
||||||
import { acpRenameSession, type SessionListItem } from '../../acp/sessions';
|
import { acpRenameSession, type SessionListItem } from '../../acp/sessions';
|
||||||
import { cn } from '../../utils';
|
import { cn } from '../../utils';
|
||||||
|
import type { ProjectGroup } from '../../utils/projectSessions';
|
||||||
import { defineMessages, useIntl } from '../../i18n';
|
import { defineMessages, useIntl } from '../../i18n';
|
||||||
|
|
||||||
type StreamState = 'idle' | 'loading' | 'streaming' | 'error';
|
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 isActive = useCallback((path: string) => location.pathname === path, [location.pathname]);
|
||||||
|
|
||||||
const { recentSessions, activeSessionId, fetchSessions, handleNavClick, handleSessionClick } =
|
const { recentSessions, recentSessionsByProject, activeSessionId, fetchSessions, handleNavClick, handleSessionClick } =
|
||||||
useNavigationSessions();
|
useNavigationSessions();
|
||||||
|
|
||||||
const [sessionStatuses, setSessionStatuses] = useState<Map<string, SessionStatus>>(new Map());
|
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" />
|
<div className="h-[48px] no-drag" />
|
||||||
|
|
||||||
{/* Nav items */}
|
|
||||||
<div className="px-2 flex flex-col gap-0.5">
|
<div className="px-2 flex flex-col gap-0.5">
|
||||||
{visibleItems.map((item) => (
|
{visibleItems.map((item) => (
|
||||||
<NavRow
|
<NavRow
|
||||||
|
|
@ -207,7 +207,6 @@ export const Navigation: React.FC<{ className?: string }> = ({ className }) => {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chats section — takes remaining vertical space */}
|
|
||||||
<div className="flex-1 min-h-0 flex flex-col mt-3">
|
<div className="flex-1 min-h-0 flex flex-col mt-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsChatsExpanded((v) => !v)}
|
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">
|
<div className="px-3 py-2 text-xs text-text-secondary">
|
||||||
{intl.formatMessage(i18n.noChats)}
|
{intl.formatMessage(i18n.noChats)}
|
||||||
</div>
|
</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) => (
|
recentSessions.map((session) => (
|
||||||
<SessionRow
|
<SessionRow
|
||||||
|
|
@ -245,7 +268,6 @@ export const Navigation: React.FC<{ className?: string }> = ({ className }) => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Settings pinned to bottom */}
|
|
||||||
<div className="px-2 pt-2 pb-2 border-t border-border-secondary">
|
<div className="px-2 pt-2 pb-2 border-t border-border-secondary">
|
||||||
<NavRow
|
<NavRow
|
||||||
item={SETTINGS_NAV_ITEM}
|
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 { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||||
import { useChatContext } from '../contexts/ChatContext';
|
import { useChatContext } from '../contexts/ChatContext';
|
||||||
import { getSessionDisplayName } from '../sessions';
|
import { getSessionDisplayName } from '../sessions';
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
acpListRecentSessions,
|
acpListRecentSessions,
|
||||||
type SessionListItem,
|
type SessionListItem,
|
||||||
} from '../acp/sessions';
|
} from '../acp/sessions';
|
||||||
|
import { groupSessionsByProject } from '../utils/projectSessions';
|
||||||
|
|
||||||
const MAX_RECENT_SESSIONS = 25;
|
const MAX_RECENT_SESSIONS = 25;
|
||||||
|
|
||||||
|
|
@ -55,6 +56,10 @@ export function useNavigationSessions() {
|
||||||
const chatContext = useChatContext();
|
const chatContext = useChatContext();
|
||||||
|
|
||||||
const [recentSessions, setRecentSessions] = useState<SessionListItem[]>([]);
|
const [recentSessions, setRecentSessions] = useState<SessionListItem[]>([]);
|
||||||
|
const recentSessionsByProject = useMemo(
|
||||||
|
() => groupSessionsByProject(recentSessions),
|
||||||
|
[recentSessions]
|
||||||
|
);
|
||||||
const lastSessionIdRef = useRef<string | null>(null);
|
const lastSessionIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const activeSessionId = searchParams.get('resumeSessionId') ?? undefined;
|
const activeSessionId = searchParams.get('resumeSessionId') ?? undefined;
|
||||||
|
|
@ -203,6 +208,7 @@ export function useNavigationSessions() {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
recentSessions,
|
recentSessions,
|
||||||
|
recentSessionsByProject,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
fetchSessions,
|
fetchSessions,
|
||||||
handleNavClick,
|
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