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': '隐藏工作区',