diff --git a/e2e/browser/boot-and-navigation.spec.ts b/e2e/browser/boot-and-navigation.spec.ts index f0dcc3ef..9e594be4 100644 --- a/e2e/browser/boot-and-navigation.spec.ts +++ b/e2e/browser/boot-and-navigation.spec.ts @@ -20,7 +20,7 @@ test('app boots on mock and primary navigation works', async ({ page }) => { await expect(page).toHaveURL(/#\/projects/); await expect(page.getByRole('heading', { name: '历史' })).toBeVisible(); - await goNav(page, '对话'); + await goNav(page, '工作区'); await expect(page).toHaveURL(/#\/chat/); await expect( page.getByRole('textbox', { name: '消息输入' }).or(page.getByText('还没有可对话的 Agent')), @@ -172,7 +172,7 @@ test('page title sits in the top bar; Chat has neither title nor in-app notifica expect(Math.abs(createBox!.y + createBox!.height / 2 - (leadBox!.y + leadBox!.height / 2))).toBeLessThanOrEqual(8); expect(Math.abs(leadBox!.y - connectionsTop)).toBeLessThanOrEqual(12); - await goNav(page, '对话'); + await goNav(page, '工作区'); await expect(page).toHaveURL(/#\/chat/); await expect(page.getByRole('button', { name: '通知' })).toHaveCount(0); await expect(page.getByRole('heading', { level: 1 })).toHaveCount(0); diff --git a/e2e/browser/helpers.ts b/e2e/browser/helpers.ts index b320a648..3e842485 100644 --- a/e2e/browser/helpers.ts +++ b/e2e/browser/helpers.ts @@ -82,7 +82,7 @@ export async function addClaudeApiKeyAndSwitch(page: Page): Promise { } export async function openChatComposer(page: Page): Promise { - await goNav(page, '对话'); + await goNav(page, '工作区'); await expect(page.getByRole('textbox', { name: '消息输入' })).toBeVisible({ timeout: 20_000, }); diff --git a/src/lib/i18n/index.test.ts b/src/lib/i18n/index.test.ts index d1e668a9..4584ed1b 100644 --- a/src/lib/i18n/index.test.ts +++ b/src/lib/i18n/index.test.ts @@ -66,6 +66,7 @@ describe('translate / interpolate', () => { expect(translate('en', 'common.save')).toBe('Save'); expect(translate('zh', 'common.save')).toBe('保存'); const t = createTranslator('en'); + expect(t('nav.chat')).toBe('Workspace'); expect(t('nav.routes')).toBe('Routes'); expect(t('routes.page.title')).toBe('Routes'); expect(t('nav.dashboard')).toBe('Dashboard'); @@ -80,7 +81,7 @@ describe('translate / interpolate', () => { expect(zhNav('nav.routes')).toBe('路由'); expect(zhNav('routes.page.title')).toBe('路由'); expect(zhNav('nav.settings')).toBe('设置'); - expect(zhNav('nav.chat')).toBe('对话'); + expect(zhNav('nav.chat')).toBe('工作区'); expect(zhNav('nav.agents')).toBe('Agent'); expect(zhNav('chrome.onboarding.enterDashboard')).toBe('进入总览'); expect(zhNav('chrome.onboarding.skipGuide')).toBe('跳过引导'); diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 457207a5..70678f0f 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -40,7 +40,7 @@ export const en = { nav: { workspace: 'Workspace', manage: 'Manage', - chat: 'Chat', + chat: 'Workspace', agents: 'Agents', skills: 'Skills', mcp: 'MCP', diff --git a/src/lib/i18n/locales/zh.ts b/src/lib/i18n/locales/zh.ts index 20348e9d..4b41b34e 100644 --- a/src/lib/i18n/locales/zh.ts +++ b/src/lib/i18n/locales/zh.ts @@ -39,7 +39,7 @@ export const zh = { nav: { workspace: '工作区', manage: '管理', - chat: '对话', + chat: '工作区', agents: 'Agent', skills: '技能', mcp: 'MCP', diff --git a/src/pages/chat/ChatSessionRail.test.ts b/src/pages/chat/ChatSessionRail.test.ts index 1387f861..46afa7ba 100644 --- a/src/pages/chat/ChatSessionRail.test.ts +++ b/src/pages/chat/ChatSessionRail.test.ts @@ -5,7 +5,11 @@ import { describe, expect, it, vi } from 'vitest'; import { TooltipProvider } from '@/components/ui/tooltip'; import type { Conversation } from '@/lib/types'; import { createTranslator } from '@/lib/i18n'; -import { conversationRailHintView, conversationSemanticTitle } from './chat-model'; +import { + conversationRailHintView, + conversationSemanticTitle, + type ConversationWorkspaceGroup, +} from './chat-model'; import { ChatSessionRail } from './ChatSessionRail'; vi.mock('@/components/shared/LanguageProvider', async () => { @@ -34,12 +38,27 @@ function renderMarkup(node: ReactElement) { return renderToStaticMarkup(createElement(TooltipProvider, null, node)); } +function workspaceGroup( + items: Conversation[], + partial?: Partial, +): ConversationWorkspaceGroup { + const cwd = items[0]?.cwd ?? null; + return { + key: cwd ? `path:${cwd}` : 'unset', + label: cwd ? 'demo-project' : '未设置工作目录', + cwd, + pathLabel: null, + items, + ...partial, + }; +} + function rail(partial?: Partial[0]>) { const item = conversation(); return createElement(ChatSessionRail, { open: true, listLoading: false, - groups: [{ key: 'today', label: '今天', items: [item] }], + groups: [workspaceGroup([item])], conversations: [item], filteredCount: 1, query: '', @@ -65,7 +84,7 @@ describe('ChatSessionRail titles', () => { 'Use your terminal to write exactly what I asked without clipping the title'; const html = renderMarkup( rail({ - groups: [{ key: 'today', label: '今天', items: [conversation({ title: full })] }], + groups: [workspaceGroup([conversation({ title: full })])], conversations: [conversation({ title: full })], firstUserContentById: { c1: full }, }), @@ -94,7 +113,7 @@ describe('ChatSessionRail titles', () => { renderMarkup( rail({ activeId: active.id, - groups: [{ key: 'today', label: '今天', items: [active, clipped] }], + groups: [workspaceGroup([active, clipped])], conversations: [active, clipped], filteredCount: 2, firstUserContentById, @@ -133,7 +152,30 @@ describe('ChatSessionRail titles', () => { expect(hint.meta).toContain('/workspace/demo-project'); const src = readFileSync(new URL('./ChatSessionRail.tsx', import.meta.url), 'utf8'); expect(src).toContain('conversationRailHintView('); - expect(src).not.toContain('cwdShortName'); + expect(src).toContain('data-help="chat-workspace-group-label"'); + }); + + it('groups sessions under a collapsible working-directory header', () => { + const html = renderMarkup(rail()); + expect(html).toContain('data-help="chat-workspace-group"'); + expect(html).toContain('data-help="chat-workspace-group-label"'); + expect(html).toContain('demo-project'); + expect(html).toContain('aria-expanded="true"'); + expect(html).not.toContain('今天'); + const src = readFileSync(new URL('./ChatSessionRail.tsx', import.meta.url), 'utf8'); + expect(src).toContain('ChevronDown'); + expect(src).toContain('ChevronRight'); + }); + + it('shows the full path when two workspaces share a short name', () => { + const item = conversation(); + const html = renderMarkup( + rail({ + groups: [workspaceGroup([item], { pathLabel: '/home/alice/demo-project' })], + }), + ); + expect(html).toContain('data-help="chat-workspace-group-path"'); + expect(html).toContain('/home/alice/demo-project'); }); it('paints 新建对话 with the theme fill', () => { diff --git a/src/pages/chat/ChatSessionRail.tsx b/src/pages/chat/ChatSessionRail.tsx index 9f4b1423..efd91b24 100644 --- a/src/pages/chat/ChatSessionRail.tsx +++ b/src/pages/chat/ChatSessionRail.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef } from 'react'; -import { Loader2, PanelLeftClose, Plus, Trash2 } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { ChevronDown, ChevronRight, Loader2, PanelLeftClose, Plus, Trash2 } from 'lucide-react'; import { AgentLogo } from '@/components/shared/AgentLogo'; import { NavResizeHandle } from '@/components/layout/NavResizeHandle'; import { pageRhythm } from '@/components/layout/page-rhythm'; @@ -28,7 +28,7 @@ import { conversationRailMarkColor, conversationRailSelectedFill, conversationTitle, - type ConversationDayGroup, + type ConversationWorkspaceGroup, } from './chat-model'; export function ChatSessionRail({ @@ -56,7 +56,7 @@ export function ChatSessionRail({ }: { open: boolean; listLoading: boolean; - groups: ConversationDayGroup[]; + groups: ConversationWorkspaceGroup[]; conversations: Conversation[]; filteredCount: number; query: string; @@ -85,6 +85,8 @@ export function ChatSessionRail({ const pending = conversations.find((c) => c.id === deleteConfirmId) ?? null; const railRef = useRef(null); const searchInputRef = useRef(null); + const [collapsedKeys, setCollapsedKeys] = useState>(() => new Set()); + const searching = Boolean(query.trim()); useEffect(() => { if (!open || !searchFocusNonce) return; const timer = window.setTimeout(() => { @@ -93,6 +95,17 @@ export function ChatSessionRail({ }, 0); return () => window.clearTimeout(timer); }, [open, searchFocusNonce]); + useEffect(() => { + if (!activeId) return; + const key = groups.find((group) => group.items.some((item) => item.id === activeId))?.key; + if (!key) return; + setCollapsedKeys((prev) => { + if (!prev.has(key)) return prev; + const next = new Set(prev); + next.delete(key); + return next; + }); + }, [activeId, groups, historyRevealNonce]); useEffect(() => { if (!open || !historyRevealNonce) return; const timer = window.setTimeout(() => { @@ -180,12 +193,47 @@ export function ChatSessionRail({

{t('chat.rail.noMatch')}

) : ( - groups.map((group) => ( -
-
- {group.label} -
- {group.items.map((c) => { + groups.map((group) => { + const expanded = searching || !collapsedKeys.has(group.key); + return ( +
+ + + + {expanded ? group.items.map((c) => { const selected = activeId === c.id; const sending = sendingConversationIds.includes(c.id); return ( @@ -255,9 +303,10 @@ export function ChatSessionRail({
); - })} + }) : null}
- )) + ); + }) )} !next && onCancelDelete()}> diff --git a/src/pages/chat/chat-layout.test.ts b/src/pages/chat/chat-layout.test.ts index 3b25e98a..27a20665 100644 --- a/src/pages/chat/chat-layout.test.ts +++ b/src/pages/chat/chat-layout.test.ts @@ -304,7 +304,8 @@ describe('chat layout wiring', () => { expect(rail).toContain('AgentLogo'); expect(rail).toContain('hint={false}'); expect(rail).not.toContain('conversationAgentLine'); - expect(rail).not.toContain('cwdShortName'); + expect(rail).toContain('data-help="chat-workspace-group"'); + expect(rail).toContain('aria-expanded'); expect(rail).toContain('conversationTitle'); expect(rail).not.toContain('isBlankConversationDraft'); expect(rail).toContain("t('chat.rail.searchPlaceholder')"); diff --git a/src/pages/chat/chat-model.test.ts b/src/pages/chat/chat-model.test.ts index d18181ad..ac24597b 100644 --- a/src/pages/chat/chat-model.test.ts +++ b/src/pages/chat/chat-model.test.ts @@ -61,7 +61,9 @@ import { isBlankConversationDraft, draftForFocusedConversation, filterConversations, - groupConversationsByDay, + conversationWorkspaceKey, + groupConversationsByWorkspace, + UNSET_WORKSPACE_KEY, isChatAgentSelectable, liveSendingIds, incomingSendingIds, @@ -375,41 +377,69 @@ describe('filterConversations', () => { }); }); -describe('groupConversationsByDay', () => { - // 本地时区 2026-08-16 15:00 - const now = new Date(2026, 7, 16, 15, 0, 0, 0).getTime(); - - function at(y: number, m: number, d: number, h = 12): string { - return new Date(y, m, d, h, 0, 0, 0).toISOString(); +describe('groupConversationsByWorkspace', () => { + function at(day: number, hour = 12): string { + return new Date(Date.UTC(2026, 7, day, hour, 0, 0)).toISOString(); } - it('buckets across local-day boundaries and drops empty groups', () => { - const today = conv({ id: 't', title: 'today', updatedAt: at(2026, 7, 16, 1) }); - const yesterday = conv({ id: 'y', title: 'yest', updatedAt: at(2026, 7, 15, 23) }); - const week = conv({ id: 'w', title: 'week', updatedAt: at(2026, 7, 11, 8) }); - const earlier = conv({ id: 'e', title: 'old', updatedAt: at(2026, 7, 9, 8) }); - const groups = groupConversationsByDay([today, yesterday, week, earlier], now, t); + it('merges the same working directory across Windows case and slash variants', () => { + const newer = conv({ + id: 'a', + cwd: 'D:\\projects\\Demo', + updatedAt: at(16, 15), + }); + const older = conv({ + id: 'b', + cwd: 'd:/projects/demo/', + updatedAt: at(15, 10), + }); + const groups = groupConversationsByWorkspace([older, newer], t); + expect(groups).toHaveLength(1); + expect(groups[0].key).toBe(conversationWorkspaceKey('D:\\projects\\Demo')); + expect(groups[0].label).toBe('Demo'); + expect(groups[0].cwd).toBe('D:\\projects\\Demo'); + expect(groups[0].pathLabel).toBeNull(); + expect(groups[0].items.map((c) => c.id)).toEqual(['a', 'b']); + }); - expect(groups.map((g) => g.key)).toEqual(['today', 'yesterday', 'week', 'earlier']); - expect(groups.map((g) => g.label)).toEqual(['今天', '昨天', '近 7 天', '更早']); - expect(groups.map((g) => g.items.map((c) => c.id))).toEqual([['t'], ['y'], ['w'], ['e']]); + it('keeps POSIX paths with different case in separate groups', () => { + const upper = conv({ id: 'A', cwd: '/tmp/App', updatedAt: at(16) }); + const lower = conv({ id: 'B', cwd: '/tmp/app', updatedAt: at(15) }); + const groups = groupConversationsByWorkspace([upper, lower], t); + expect(groups.map((g) => g.cwd)).toEqual(['/tmp/App', '/tmp/app']); + expect(groups[0].key).not.toBe(groups[1].key); }); - it('keeps input order inside a group and omits empty buckets', () => { - const a = conv({ id: 'a', updatedAt: at(2026, 7, 16, 14) }); - const b = conv({ id: 'b', updatedAt: at(2026, 7, 16, 10) }); - const groups = groupConversationsByDay([a, b], now, t); - expect(groups).toHaveLength(1); - expect(groups[0].key).toBe('today'); - expect(groups[0].items.map((c) => c.id)).toEqual(['a', 'b']); + it('puts sessions without a working directory in one unset group', () => { + const withDir = conv({ id: 'dir', cwd: '/tmp/app', updatedAt: at(16) }); + const empty = conv({ id: 'empty', cwd: ' ', updatedAt: at(15) }); + const missing = conv({ id: 'missing', cwd: null, updatedAt: at(14) }); + const groups = groupConversationsByWorkspace([withDir, empty, missing], t); + expect(groups.map((g) => g.key)).toEqual([ + conversationWorkspaceKey('/tmp/app'), + UNSET_WORKSPACE_KEY, + ]); + expect(groups[1].label).toBe('未设置工作目录'); + expect(groups[1].cwd).toBeNull(); + expect(groups[1].items.map((c) => c.id)).toEqual(['empty', 'missing']); + expect(groups[0].items.map((c) => c.id)).toEqual(['dir']); }); - it('puts today-minus-6 in week and today-minus-7 in earlier', () => { - const sixDays = conv({ id: 's', updatedAt: at(2026, 7, 10, 12) }); - const sevenDays = conv({ id: 'v', updatedAt: at(2026, 7, 9, 12) }); - const groups = groupConversationsByDay([sixDays, sevenDays], now, t); - expect(groups.find((g) => g.key === 'week')?.items.map((c) => c.id)).toEqual(['s']); - expect(groups.find((g) => g.key === 'earlier')?.items.map((c) => c.id)).toEqual(['v']); + it('sorts groups and items by updated time, newest first', () => { + const olderApp = conv({ id: 'old-app', cwd: '/tmp/app', updatedAt: at(10) }); + const newerApp = conv({ id: 'new-app', cwd: '/tmp/app', updatedAt: at(16) }); + const other = conv({ id: 'other', cwd: '/tmp/other', updatedAt: at(12) }); + const groups = groupConversationsByWorkspace([olderApp, other, newerApp], t); + expect(groups.map((g) => g.cwd)).toEqual(['/tmp/app', '/tmp/other']); + expect(groups[0].items.map((c) => c.id)).toEqual(['new-app', 'old-app']); + }); + + it('shows the full path when two workspaces share a short name', () => { + const alice = conv({ id: 'alice', cwd: '/home/alice/app', updatedAt: at(16) }); + const bob = conv({ id: 'bob', cwd: '/home/bob/app', updatedAt: at(15) }); + const groups = groupConversationsByWorkspace([alice, bob], t); + expect(groups.map((g) => g.label)).toEqual(['app', 'app']); + expect(groups.map((g) => g.pathLabel)).toEqual(['/home/alice/app', '/home/bob/app']); }); }); @@ -598,6 +628,26 @@ describe('newConversationDefaults', () => { cwd: '/tmp/app', }); }); + + it('places a new session in the current workspace group', () => { + const active = conv({ + id: 'a', + agentIds: ['claude'], + cwd: '/tmp/app', + updatedAt: '2026-08-16T10:00:00.000Z', + }); + const defaults = newConversationDefaults(active, agents); + const created = conv({ + id: 'new', + agentIds: defaults.agentIds, + cwd: defaults.cwd, + updatedAt: '2026-08-16T12:00:00.000Z', + }); + const groups = groupConversationsByWorkspace([created, active], t); + expect(defaults.cwd).toBe('/tmp/app'); + expect(groups).toHaveLength(1); + expect(groups[0].items.map((c) => c.id)).toEqual(['new', 'a']); + }); }); describe('selectConversationAgent', () => { diff --git a/src/pages/chat/chat-model.ts b/src/pages/chat/chat-model.ts index 942091b6..199164a8 100644 --- a/src/pages/chat/chat-model.ts +++ b/src/pages/chat/chat-model.ts @@ -23,6 +23,7 @@ import type { ChatMessageStatus, Conversation, } from '@/lib/types'; +import { normalizeProjectMergePath } from '@/pages/projects/project-groups'; import { relativeTime, type TurnGroup } from './chat-format'; import { streamingStatusKey } from './chat-streaming'; @@ -166,21 +167,18 @@ export function chatAgentPickerEmptyCopy(t: TranslateFn, kind: ChatPickerEmptyKi return { text: t('chat.picker.none'), action: t('chat.picker.goAgents') }; } -export type ConversationDayKey = 'today' | 'yesterday' | 'week' | 'earlier'; +/** Conversations without a working directory share this rail group. */ +export const UNSET_WORKSPACE_KEY = 'unset'; -export type ConversationDayGroup = { - key: ConversationDayKey; +export type ConversationWorkspaceGroup = { + key: string; label: string; + cwd: string | null; + /** Full path under the short name when two workspaces share that name. */ + pathLabel: string | null; items: Conversation[]; }; -const DAY_KEYS: Record = { - today: 'chat.day.today', - yesterday: 'chat.day.yesterday', - week: 'chat.day.week', - earlier: 'chat.day.earlier', -}; - const RETRY_STATUSES = new Set(['failed', 'cancelled', 'timeout']); /** Interactive TUI resume command for a Hub conversation, when a native id is known. */ @@ -226,51 +224,72 @@ export function filterConversations(convs: Conversation[], query: string): Conve }); } -function startOfLocalDay(ms: number): Date { - const d = new Date(ms); - d.setHours(0, 0, 0, 0); - return d; -} - function parseUpdatedAt(iso: string): number { const t = Date.parse(iso.includes('T') ? iso : `${iso.replace(' ', 'T')}Z`); return Number.isNaN(t) ? 0 : t; } -export function groupConversationsByDay( +function trimmedConversationCwd(cwd: string | null | undefined): string | null { + const trimmed = cwd?.trim(); + return trimmed ? trimmed : null; +} + +/** Same merge key as history-page path groups: Windows case-insensitive, POSIX not. */ +export function conversationWorkspaceKey(cwd: string | null | undefined): string { + const trimmed = trimmedConversationCwd(cwd); + if (!trimmed) return UNSET_WORKSPACE_KEY; + return `path:${normalizeProjectMergePath(trimmed)}`; +} + +function compareConversationsByUpdatedAt(a: Conversation, b: Conversation): number { + return parseUpdatedAt(b.updatedAt) - parseUpdatedAt(a.updatedAt) || a.id.localeCompare(b.id); +} + +/** + * Group Hub conversations by working directory. Sessions without a folder stay + * in one unset group and never mix with path groups. + */ +export function groupConversationsByWorkspace( convs: Conversation[], - nowMs: number, t: TranslateFn, -): ConversationDayGroup[] { - const today = startOfLocalDay(nowMs); - const yesterday = new Date(today); - yesterday.setDate(yesterday.getDate() - 1); - const week = new Date(today); - week.setDate(week.getDate() - 6); - - const todayStart = today.getTime(); - const yesterdayStart = yesterday.getTime(); - const weekStart = week.getTime(); - - const buckets: Record = { - today: [], - yesterday: [], - week: [], - earlier: [], - }; +): ConversationWorkspaceGroup[] { + const buckets = new Map(); + for (const conversation of convs) { + const key = conversationWorkspaceKey(conversation.cwd); + const list = buckets.get(key); + if (list) list.push(conversation); + else buckets.set(key, [conversation]); + } - for (const c of convs) { - const ts = parseUpdatedAt(c.updatedAt); - if (ts >= todayStart) buckets.today.push(c); - else if (ts >= yesterdayStart) buckets.yesterday.push(c); - else if (ts >= weekStart) buckets.week.push(c); - else buckets.earlier.push(c); + const groups: ConversationWorkspaceGroup[] = []; + for (const [key, items] of buckets) { + const sortedItems = [...items].sort(compareConversationsByUpdatedAt); + const primary = sortedItems[0]; + const cwd = trimmedConversationCwd(primary?.cwd); + groups.push({ + key, + label: key === UNSET_WORKSPACE_KEY ? t('chat.header.cwdUnset') : cwdShortName(cwd, t), + cwd, + pathLabel: null, + items: sortedItems, + }); } - const order: ConversationDayKey[] = ['today', 'yesterday', 'week', 'earlier']; - return order - .filter((key) => buckets[key].length > 0) - .map((key) => ({ key, label: t(DAY_KEYS[key]), items: buckets[key] })); + const shortNameCounts = new Map(); + for (const group of groups) { + if (group.key === UNSET_WORKSPACE_KEY) continue; + shortNameCounts.set(group.label, (shortNameCounts.get(group.label) ?? 0) + 1); + } + for (const group of groups) { + if (group.key === UNSET_WORKSPACE_KEY || !group.cwd) continue; + if ((shortNameCounts.get(group.label) ?? 0) > 1) group.pathLabel = group.cwd; + } + + return groups.sort((a, b) => { + const aTs = parseUpdatedAt(a.items[0]?.updatedAt ?? ''); + const bTs = parseUpdatedAt(b.items[0]?.updatedAt ?? ''); + return bTs - aTs || a.label.localeCompare(b.label) || a.key.localeCompare(b.key); + }); } export function sendBlockers(input: { diff --git a/src/pages/chat/use-chat-page.ts b/src/pages/chat/use-chat-page.ts index 26b5bad4..56aae2ad 100644 --- a/src/pages/chat/use-chat-page.ts +++ b/src/pages/chat/use-chat-page.ts @@ -26,7 +26,7 @@ import { firstUserContentByListedConversations, filterConversations, mergeFirstUserContentById, - groupConversationsByDay, + groupConversationsByWorkspace, isChatAgentSelectable, savedLoginAgentIdsFromWallet, selectConversationAgent, @@ -600,7 +600,7 @@ export function useChatPage() { const railGroups = useMemo(() => { const filtered = filterConversations(conversations, railQuery); - return groupConversationsByDay(filtered, Date.now(), t); + return groupConversationsByWorkspace(filtered, t); }, [conversations, railQuery, t]); const filteredCount = useMemo(