From 318bf336b0a1e38f49a4b700b7f4c85eafdbddbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 19 May 2026 22:13:54 +0800 Subject: [PATCH] Expose active session state across desktop navigation Multiple desktop sessions can run at the same time, so navigation surfaces need a compact shared signal instead of making users open each conversation to check progress. The sidebar and tab bar now derive running state from both persisted tab status and live chat state, while session rows keep worktree and updated-time metadata aligned on the right. Constraint: Reuse existing tab/chat state without changing session persistence or server APIs Rejected: Add another session status field | duplicated state would drift from chatStore and tabStore Confidence: high Scope-risk: narrow Directive: Keep sidebar and tab running indicators sourced from the same state rules Tested: cd desktop && bun run test src/components/layout/TabBar.test.tsx src/components/layout/Sidebar.test.tsx --pool forks --poolOptions.forks.singleFork=true Tested: cd desktop && bun run lint Tested: bun run check:desktop Tested: real desktop session in /private/tmp/cc-haha-real-status-fsip8S using gpt-5.5 Sub2API-ChatGPT; running markers appeared in sidebar and tab, then cleared after CCH_STATUS_REAL_DONE --- .../src/components/layout/Sidebar.test.tsx | 39 ++++++++ desktop/src/components/layout/Sidebar.tsx | 95 +++++++++++++++---- desktop/src/components/layout/TabBar.test.tsx | 31 ++++++ desktop/src/components/layout/TabBar.tsx | 27 +++++- desktop/src/i18n/locales/en.ts | 2 + desktop/src/i18n/locales/zh.ts | 2 + 6 files changed, 175 insertions(+), 21 deletions(-) diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index 06c6e454..e6e41dbf 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -64,6 +64,7 @@ vi.mock('../../i18n', () => ({ 'sidebar.expandProject': 'Expand {project}', 'sidebar.collapseProject': 'Collapse {project}', 'sidebar.worktree': 'worktree', + 'sidebar.sessionRunning': 'Session running', 'common.retry': 'Retry', 'common.loading': 'Loading...', 'common.cancel': 'Cancel', @@ -90,6 +91,11 @@ vi.mock('../../i18n', () => ({ 'sidebar.batchDeleteFailed': '{count} sessions could not be deleted.', 'sidebar.collapse': 'Collapse sidebar', 'sidebar.expand': 'Expand sidebar', + 'session.lastUpdated': 'last updated {time}', + 'session.timeJustNow': 'just now', + 'session.timeMinutes': '{n}m ago', + 'session.timeHours': '{n}h ago', + 'session.timeDays': '{n}d ago', } let text = translations[key] ?? key @@ -776,6 +782,39 @@ describe('Sidebar', () => { expect(screen.getAllByText('worktree')).toHaveLength(1) }) + it('right-aligns running status, worktree marker, and update time on session rows', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-19T12:00:00.000Z')) + + useSessionStore.setState({ + sessions: [ + { + ...makeSession('running-worktree', 'Running Worktree', '/workspace/repo/.claude/worktrees/desktop-main-12345678', '2026-05-19T07:00:00.000Z'), + projectRoot: '/workspace/repo', + }, + makeSession('idle-source', 'Idle Source', '/workspace/repo', '2026-05-19T11:40:00.000Z'), + ], + }) + useTabStore.setState({ + tabs: [ + { sessionId: 'running-worktree', title: 'Running Worktree', type: 'session', status: 'running' }, + { sessionId: 'idle-source', title: 'Idle Source', type: 'session', status: 'idle' }, + ], + activeTabId: 'running-worktree', + }) + + render() + + const runningRow = screen.getByRole('button', { name: /Running Worktree/ }) + expect(within(runningRow).getByLabelText('Session running')).toBeInTheDocument() + expect(within(runningRow).getByText('worktree')).toHaveClass('sr-only') + expect(within(runningRow).getByText('5h ago')).toBeInTheDocument() + + const idleRow = screen.getByRole('button', { name: /Idle Source/ }) + expect(within(idleRow).queryByLabelText('Session running')).not.toBeInTheDocument() + expect(within(idleRow).getByText('20m ago')).toBeInTheDocument() + }) + it('shows a toast when session creation fails', async () => { createSession.mockRejectedValue(new Error('boom')) diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 8ac7685c..9860caeb 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -1,8 +1,8 @@ import { useEffect, useState, useCallback, useMemo, useRef } from 'react' -import { Check, ChevronDown, Clock, Folder, FolderOpen, FolderPlus, MoreHorizontal, Pin, PinOff, RefreshCw, RotateCcw, SquarePen, X } from 'lucide-react' +import { Check, ChevronDown, Clock, Folder, FolderOpen, FolderPlus, GitBranch, LoaderCircle, MoreHorizontal, Pin, PinOff, RefreshCw, RotateCcw, SquarePen, X } from 'lucide-react' import { useSessionStore } from '../../stores/sessionStore' import { useUIStore } from '../../stores/uiStore' -import { useTranslation } from '../../i18n' +import { useTranslation, type TranslationKey } from '../../i18n' import { ConfirmDialog } from '../shared/ConfirmDialog' import type { SessionListItem } from '../../types/session' import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore' @@ -59,6 +59,8 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { const sidebarOpen = useUIStore((s) => s.sidebarOpen) const toggleSidebar = useUIStore((s) => s.toggleSidebar) const activeTabId = useTabStore((s) => s.activeTabId) + const tabs = useTabStore((s) => s.tabs) + const chatSessions = useChatStore((s) => s.sessions) const closeTab = useTabStore((s) => s.closeTab) const disconnectSession = useChatStore((s) => s.disconnectSession) const [searchQuery, setSearchQuery] = useState('') @@ -130,6 +132,16 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { () => new Map(sessions.map((session) => [session.id, session])), [sessions], ) + const runningSessionIds = useMemo(() => { + const ids = new Set() + for (const tab of tabs) { + if (tab.type === 'session' && tab.status === 'running') ids.add(tab.sessionId) + } + for (const [sessionId, sessionState] of Object.entries(chatSessions)) { + if (sessionState.chatState !== 'idle') ids.add(sessionId) + } + return ids + }, [chatSessions, tabs]) const pendingBatchDeleteSessions = useMemo( () => (pendingBatchDeleteSessionIds ?? []) .map((sessionId) => sessionsById.get(sessionId)) @@ -986,14 +998,12 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { {t('sidebar.missingDir')} )} - {isWorktreeSession(session) && ( - - {t('sidebar.worktree')} - - )} - - {formatRelativeTime(session.modifiedAt)} - + )} @@ -1793,6 +1803,50 @@ function ProjectMenuItem({ ) } +function SessionRowMeta({ + isRunning, + isWorktree, + modifiedAt, + t, +}: { + isRunning: boolean + isWorktree: boolean + modifiedAt: string + t: (key: TranslationKey, params?: Record) => string +}) { + const relativeTime = formatRelativeTime(modifiedAt, t) + const updatedLabel = t('session.lastUpdated', { time: relativeTime }) + + return ( + + {isRunning && ( + + + )} + {isWorktree && ( + + + )} + + {relativeTime} + + + ) +} + function NavItem({ active, collapsed, @@ -1834,16 +1888,23 @@ function NavItem({ ) } -function formatRelativeTime(dateStr: string): string { - const diff = Date.now() - new Date(dateStr).getTime() +function formatRelativeTime( + dateStr: string, + t: (key: TranslationKey, params?: Record) => string, +): string { + const date = new Date(dateStr) + const timestamp = date.getTime() + if (!Number.isFinite(timestamp)) return '' + + const diff = Date.now() - timestamp const min = Math.floor(diff / 60000) - if (min < 1) return 'now' - if (min < 60) return `${min}m` + if (min < 1) return t('session.timeJustNow') + if (min < 60) return t('session.timeMinutes', { n: min }) const hr = Math.floor(min / 60) - if (hr < 24) return `${hr}h` + if (hr < 24) return t('session.timeHours', { n: hr }) const day = Math.floor(hr / 24) - if (day < 30) return `${day}d` - return `${Math.floor(day / 30)}mo` + if (day < 30) return t('session.timeDays', { n: day }) + return new Intl.DateTimeFormat(undefined, { month: 'numeric', day: 'numeric' }).format(date) } function GitHubIcon() { diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index 584edefe..4512cb6b 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -60,6 +60,7 @@ vi.mock('../../i18n', () => ({ 'tabs.closeAllConfirmTitle': 'Sessions Running', 'tabs.closeAllConfirmMessage': '{count} sessions still running', 'tabs.closeAllConfirmStop': 'Stop All & Close', + 'tabs.sessionRunning': 'Session running', 'tabs.openTerminal': 'Open Terminal', 'tabs.showWorkspace': 'Show Workspace', 'tabs.hideWorkspace': 'Hide Workspace', @@ -834,4 +835,34 @@ describe('TabBar', () => { expect(disconnectSession).toHaveBeenCalledWith('tab-idle') expect(useTabStore.getState().tabs).toEqual([]) }) + + it('shows a running marker on tabs from tab status or live chat state', async () => { + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + + useTabStore.setState({ + tabs: [ + { sessionId: 'tab-status-running', title: 'Status Running', type: 'session', status: 'running' }, + { sessionId: 'tab-chat-running', title: 'Chat Running', type: 'session', status: 'idle' }, + { sessionId: 'tab-idle', title: 'Idle', type: 'session', status: 'idle' }, + ], + activeTabId: 'tab-status-running', + }) + useChatStore.setState({ + sessions: { + 'tab-status-running': makeChatSession('idle'), + 'tab-chat-running': makeChatSession('thinking'), + 'tab-idle': makeChatSession('idle'), + }, + disconnectSession: vi.fn(), + } as Partial>) + + await act(async () => { + render() + }) + + expect(screen.getAllByLabelText('Session running')).toHaveLength(2) + expect(screen.getByText('Idle').closest('[data-dragging]')?.querySelector('[aria-label="Session running"]')).toBeNull() + }) }) diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index b4a4ca2a..25495157 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useRef, useState, useEffect, useCallback } from 'react' +import { forwardRef, useMemo, useRef, useState, useEffect, useCallback } from 'react' import { SCHEDULED_TAB_ID, SETTINGS_TAB_ID, @@ -44,6 +44,7 @@ export function TabBar() { const activeTabId = useTabStore((s) => s.activeTabId) const setActiveTab = useTabStore((s) => s.setActiveTab) const closeTab = useTabStore((s) => s.closeTab) + const chatSessions = useChatStore((s) => s.sessions) const disconnectSession = useChatStore((s) => s.disconnectSession) const activeTab = tabs.find((tab) => tab.sessionId === activeTabId) ?? null const isActiveSessionTab = isSessionTab(activeTab) || isSessionTabId(activeTabId) @@ -75,6 +76,16 @@ export function TabBar() { const tabRefs = useRef(new Map()) const startDraggingRef = useRef<(() => Promise) | null>(null) const t = useTranslation() + const runningSessionIds = useMemo(() => { + const ids = new Set() + for (const tab of tabs) { + if (isSessionTab(tab) && tab.status === 'running') ids.add(tab.sessionId) + } + for (const [sessionId, sessionState] of Object.entries(chatSessions)) { + if (sessionState.chatState !== 'idle') ids.add(sessionId) + } + return ids + }, [chatSessions, tabs]) useEffect(() => { if (!isTauri) return @@ -332,10 +343,12 @@ export function TabBar() { key={tab.sessionId} ref={(node) => { tabRefs.current.set(tab.sessionId, node) }} tab={tab} + isRunning={runningSessionIds.has(tab.sessionId)} isActive={tab.sessionId === activeTabId} isDragOver={dragOverIndex === index} isDragging={tab.sessionId === draggingSessionId} dragOffsetX={tab.sessionId === draggingSessionId ? dragOffsetX : 0} + runningLabel={t('tabs.sessionRunning')} onClick={() => handleTabClick(tab.sessionId)} onClose={() => handleClose(tab.sessionId)} onContextMenu={(e) => handleContextMenu(e, tab.sessionId)} @@ -473,15 +486,17 @@ export function TabBar() { const TabItem = forwardRef void onClose: () => void onContextMenu: (e: React.MouseEvent) => void onMouseDown: (event: React.MouseEvent) => void -}>(({ tab, isActive, isDragOver, isDragging, dragOffsetX, onClick, onClose, onContextMenu, onMouseDown }, ref) => { +}>(({ tab, isRunning, isActive, isDragOver, isDragging, dragOffsetX, runningLabel, onClick, onClose, onContextMenu, onMouseDown }, ref) => { return (
- {tab.type === 'session' && tab.status === 'running' && ( - + {tab.type === 'session' && isRunning && ( + )} {tab.type === 'session' && tab.status === 'error' && ( diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index f31d0b6d..43466957 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -56,6 +56,7 @@ export const en = { 'sidebar.expandProject': 'Expand {project}', 'sidebar.collapseProject': 'Collapse {project}', 'sidebar.worktree': 'worktree', + 'sidebar.sessionRunning': 'Session running', 'sidebar.missingDir': 'missing dir', 'sidebar.confirmDelete': 'Delete this session? This cannot be undone.', 'sidebar.batchManage': 'Batch manage', @@ -1509,6 +1510,7 @@ export const en = { 'tabs.closeAllConfirmTitle': 'Sessions Running', 'tabs.closeAllConfirmMessage': '{count} sessions are still running. Stop them before closing these tabs?', 'tabs.closeAllConfirmStop': 'Stop All & Close', + 'tabs.sessionRunning': 'Session running', 'tabs.openTerminal': 'Open Terminal', 'tabs.showWorkspace': 'Show Workspace', 'tabs.hideWorkspace': 'Hide Workspace', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 350509b0..db472bb9 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -58,6 +58,7 @@ export const zh: Record = { 'sidebar.expandProject': '展开 {project}', 'sidebar.collapseProject': '折叠 {project}', 'sidebar.worktree': 'worktree', + 'sidebar.sessionRunning': '会话运行中', 'sidebar.missingDir': '目录缺失', 'sidebar.confirmDelete': '确定要删除这个会话吗?此操作不可撤销。', 'sidebar.batchManage': '批量管理', @@ -1511,6 +1512,7 @@ export const zh: Record = { 'tabs.closeAllConfirmTitle': '多个会话运行中', 'tabs.closeAllConfirmMessage': '仍有 {count} 个会话正在运行。关闭这些标签前是否停止它们?', 'tabs.closeAllConfirmStop': '全部停止并关闭', + 'tabs.sessionRunning': '会话运行中', 'tabs.openTerminal': '打开终端', 'tabs.showWorkspace': '显示工作区', 'tabs.hideWorkspace': '隐藏工作区',