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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-19 22:13:54 +08:00
parent fed9b6c9f6
commit 318bf336b0
6 changed files with 175 additions and 21 deletions

View File

@ -64,6 +64,7 @@ vi.mock('../../i18n', () => ({
'sidebar.expandProject': 'Expand {project}', 'sidebar.expandProject': 'Expand {project}',
'sidebar.collapseProject': 'Collapse {project}', 'sidebar.collapseProject': 'Collapse {project}',
'sidebar.worktree': 'worktree', 'sidebar.worktree': 'worktree',
'sidebar.sessionRunning': 'Session running',
'common.retry': 'Retry', 'common.retry': 'Retry',
'common.loading': 'Loading...', 'common.loading': 'Loading...',
'common.cancel': 'Cancel', 'common.cancel': 'Cancel',
@ -90,6 +91,11 @@ vi.mock('../../i18n', () => ({
'sidebar.batchDeleteFailed': '{count} sessions could not be deleted.', 'sidebar.batchDeleteFailed': '{count} sessions could not be deleted.',
'sidebar.collapse': 'Collapse sidebar', 'sidebar.collapse': 'Collapse sidebar',
'sidebar.expand': 'Expand 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 let text = translations[key] ?? key
@ -776,6 +782,39 @@ describe('Sidebar', () => {
expect(screen.getAllByText('worktree')).toHaveLength(1) 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(<Sidebar />)
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 () => { it('shows a toast when session creation fails', async () => {
createSession.mockRejectedValue(new Error('boom')) createSession.mockRejectedValue(new Error('boom'))

View File

@ -1,8 +1,8 @@
import { useEffect, useState, useCallback, useMemo, useRef } from 'react' 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 { useSessionStore } from '../../stores/sessionStore'
import { useUIStore } from '../../stores/uiStore' import { useUIStore } from '../../stores/uiStore'
import { useTranslation } from '../../i18n' import { useTranslation, type TranslationKey } from '../../i18n'
import { ConfirmDialog } from '../shared/ConfirmDialog' import { ConfirmDialog } from '../shared/ConfirmDialog'
import type { SessionListItem } from '../../types/session' import type { SessionListItem } from '../../types/session'
import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore' 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 sidebarOpen = useUIStore((s) => s.sidebarOpen)
const toggleSidebar = useUIStore((s) => s.toggleSidebar) const toggleSidebar = useUIStore((s) => s.toggleSidebar)
const activeTabId = useTabStore((s) => s.activeTabId) 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 closeTab = useTabStore((s) => s.closeTab)
const disconnectSession = useChatStore((s) => s.disconnectSession) const disconnectSession = useChatStore((s) => s.disconnectSession)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
@ -130,6 +132,16 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
() => new Map(sessions.map((session) => [session.id, session])), () => new Map(sessions.map((session) => [session.id, session])),
[sessions], [sessions],
) )
const runningSessionIds = useMemo(() => {
const ids = new Set<string>()
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( const pendingBatchDeleteSessions = useMemo(
() => (pendingBatchDeleteSessionIds ?? []) () => (pendingBatchDeleteSessionIds ?? [])
.map((sessionId) => sessionsById.get(sessionId)) .map((sessionId) => sessionsById.get(sessionId))
@ -986,14 +998,12 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
{t('sidebar.missingDir')} {t('sidebar.missingDir')}
</span> </span>
)} )}
{isWorktreeSession(session) && ( <SessionRowMeta
<span className="flex-shrink-0 rounded bg-[var(--color-sidebar-item-hover)] px-1 py-0.5 text-[9px] font-medium uppercase tracking-wide text-[var(--color-text-tertiary)]"> isRunning={runningSessionIds.has(session.id)}
{t('sidebar.worktree')} isWorktree={isWorktreeSession(session)}
</span> modifiedAt={session.modifiedAt}
)} t={t}
<span className="flex-shrink-0 text-[10px] text-[var(--color-text-tertiary)] opacity-0 transition-opacity group-hover/session:opacity-100"> />
{formatRelativeTime(session.modifiedAt)}
</span>
</span> </span>
</button> </button>
)} )}
@ -1793,6 +1803,50 @@ function ProjectMenuItem({
) )
} }
function SessionRowMeta({
isRunning,
isWorktree,
modifiedAt,
t,
}: {
isRunning: boolean
isWorktree: boolean
modifiedAt: string
t: (key: TranslationKey, params?: Record<string, string | number>) => string
}) {
const relativeTime = formatRelativeTime(modifiedAt, t)
const updatedLabel = t('session.lastUpdated', { time: relativeTime })
return (
<span
className="ml-auto flex h-5 min-w-[78px] flex-shrink-0 items-center justify-end gap-1.5 text-[10px] font-medium tabular-nums text-[var(--color-text-tertiary)]"
title={updatedLabel}
>
{isRunning && (
<span
className="inline-flex h-4 w-4 flex-shrink-0 items-center justify-center text-[var(--color-success)]"
aria-label={t('sidebar.sessionRunning')}
title={t('sidebar.sessionRunning')}
>
<LoaderCircle className="h-3.5 w-3.5 animate-spin" strokeWidth={2.2} aria-hidden="true" />
</span>
)}
{isWorktree && (
<span
className="inline-flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] text-[var(--color-text-tertiary)]"
title={t('sidebar.worktree')}
>
<GitBranch className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
<span className="sr-only">{t('sidebar.worktree')}</span>
</span>
)}
<span className="inline-flex min-w-[42px] flex-shrink-0 items-center justify-end">
<span>{relativeTime}</span>
</span>
</span>
)
}
function NavItem({ function NavItem({
active, active,
collapsed, collapsed,
@ -1834,16 +1888,23 @@ function NavItem({
) )
} }
function formatRelativeTime(dateStr: string): string { function formatRelativeTime(
const diff = Date.now() - new Date(dateStr).getTime() dateStr: string,
t: (key: TranslationKey, params?: Record<string, string | number>) => 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) const min = Math.floor(diff / 60000)
if (min < 1) return 'now' if (min < 1) return t('session.timeJustNow')
if (min < 60) return `${min}m` if (min < 60) return t('session.timeMinutes', { n: min })
const hr = Math.floor(min / 60) 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) const day = Math.floor(hr / 24)
if (day < 30) return `${day}d` if (day < 30) return t('session.timeDays', { n: day })
return `${Math.floor(day / 30)}mo` return new Intl.DateTimeFormat(undefined, { month: 'numeric', day: 'numeric' }).format(date)
} }
function GitHubIcon() { function GitHubIcon() {

View File

@ -60,6 +60,7 @@ vi.mock('../../i18n', () => ({
'tabs.closeAllConfirmTitle': 'Sessions Running', 'tabs.closeAllConfirmTitle': 'Sessions Running',
'tabs.closeAllConfirmMessage': '{count} sessions still running', 'tabs.closeAllConfirmMessage': '{count} sessions still running',
'tabs.closeAllConfirmStop': 'Stop All & Close', 'tabs.closeAllConfirmStop': 'Stop All & Close',
'tabs.sessionRunning': 'Session running',
'tabs.openTerminal': 'Open Terminal', 'tabs.openTerminal': 'Open Terminal',
'tabs.showWorkspace': 'Show Workspace', 'tabs.showWorkspace': 'Show Workspace',
'tabs.hideWorkspace': 'Hide Workspace', 'tabs.hideWorkspace': 'Hide Workspace',
@ -834,4 +835,34 @@ describe('TabBar', () => {
expect(disconnectSession).toHaveBeenCalledWith('tab-idle') expect(disconnectSession).toHaveBeenCalledWith('tab-idle')
expect(useTabStore.getState().tabs).toEqual([]) 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<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
expect(screen.getAllByLabelText('Session running')).toHaveLength(2)
expect(screen.getByText('Idle').closest('[data-dragging]')?.querySelector('[aria-label="Session running"]')).toBeNull()
})
}) })

View File

@ -1,4 +1,4 @@
import { forwardRef, useRef, useState, useEffect, useCallback } from 'react' import { forwardRef, useMemo, useRef, useState, useEffect, useCallback } from 'react'
import { import {
SCHEDULED_TAB_ID, SCHEDULED_TAB_ID,
SETTINGS_TAB_ID, SETTINGS_TAB_ID,
@ -44,6 +44,7 @@ export function TabBar() {
const activeTabId = useTabStore((s) => s.activeTabId) const activeTabId = useTabStore((s) => s.activeTabId)
const setActiveTab = useTabStore((s) => s.setActiveTab) const setActiveTab = useTabStore((s) => s.setActiveTab)
const closeTab = useTabStore((s) => s.closeTab) const closeTab = useTabStore((s) => s.closeTab)
const chatSessions = useChatStore((s) => s.sessions)
const disconnectSession = useChatStore((s) => s.disconnectSession) const disconnectSession = useChatStore((s) => s.disconnectSession)
const activeTab = tabs.find((tab) => tab.sessionId === activeTabId) ?? null const activeTab = tabs.find((tab) => tab.sessionId === activeTabId) ?? null
const isActiveSessionTab = isSessionTab(activeTab) || isSessionTabId(activeTabId) const isActiveSessionTab = isSessionTab(activeTab) || isSessionTabId(activeTabId)
@ -75,6 +76,16 @@ export function TabBar() {
const tabRefs = useRef(new Map<string, HTMLDivElement | null>()) const tabRefs = useRef(new Map<string, HTMLDivElement | null>())
const startDraggingRef = useRef<(() => Promise<void>) | null>(null) const startDraggingRef = useRef<(() => Promise<void>) | null>(null)
const t = useTranslation() const t = useTranslation()
const runningSessionIds = useMemo(() => {
const ids = new Set<string>()
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(() => { useEffect(() => {
if (!isTauri) return if (!isTauri) return
@ -332,10 +343,12 @@ export function TabBar() {
key={tab.sessionId} key={tab.sessionId}
ref={(node) => { tabRefs.current.set(tab.sessionId, node) }} ref={(node) => { tabRefs.current.set(tab.sessionId, node) }}
tab={tab} tab={tab}
isRunning={runningSessionIds.has(tab.sessionId)}
isActive={tab.sessionId === activeTabId} isActive={tab.sessionId === activeTabId}
isDragOver={dragOverIndex === index} isDragOver={dragOverIndex === index}
isDragging={tab.sessionId === draggingSessionId} isDragging={tab.sessionId === draggingSessionId}
dragOffsetX={tab.sessionId === draggingSessionId ? dragOffsetX : 0} dragOffsetX={tab.sessionId === draggingSessionId ? dragOffsetX : 0}
runningLabel={t('tabs.sessionRunning')}
onClick={() => handleTabClick(tab.sessionId)} onClick={() => handleTabClick(tab.sessionId)}
onClose={() => handleClose(tab.sessionId)} onClose={() => handleClose(tab.sessionId)}
onContextMenu={(e) => handleContextMenu(e, tab.sessionId)} onContextMenu={(e) => handleContextMenu(e, tab.sessionId)}
@ -473,15 +486,17 @@ export function TabBar() {
const TabItem = forwardRef<HTMLDivElement, { const TabItem = forwardRef<HTMLDivElement, {
tab: Tab tab: Tab
isRunning: boolean
isActive: boolean isActive: boolean
isDragOver: boolean isDragOver: boolean
isDragging: boolean isDragging: boolean
dragOffsetX: number dragOffsetX: number
runningLabel: string
onClick: () => void onClick: () => void
onClose: () => void onClose: () => void
onContextMenu: (e: React.MouseEvent) => void onContextMenu: (e: React.MouseEvent) => void
onMouseDown: (event: 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 ( return (
<div <div
ref={ref} ref={ref}
@ -506,8 +521,12 @@ const TabItem = forwardRef<HTMLDivElement, {
transform: isDragging ? `translateX(${dragOffsetX}px) scale(1.02)` : undefined, transform: isDragging ? `translateX(${dragOffsetX}px) scale(1.02)` : undefined,
}} }}
> >
{tab.type === 'session' && tab.status === 'running' && ( {tab.type === 'session' && isRunning && (
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)] animate-pulse flex-shrink-0" /> <span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-[var(--color-success)] animate-pulse"
aria-label={runningLabel}
title={runningLabel}
/>
)} )}
{tab.type === 'session' && tab.status === 'error' && ( {tab.type === 'session' && tab.status === 'error' && (
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-error)] flex-shrink-0" /> <span className="w-1.5 h-1.5 rounded-full bg-[var(--color-error)] flex-shrink-0" />

View File

@ -56,6 +56,7 @@ export const en = {
'sidebar.expandProject': 'Expand {project}', 'sidebar.expandProject': 'Expand {project}',
'sidebar.collapseProject': 'Collapse {project}', 'sidebar.collapseProject': 'Collapse {project}',
'sidebar.worktree': 'worktree', 'sidebar.worktree': 'worktree',
'sidebar.sessionRunning': 'Session running',
'sidebar.missingDir': 'missing dir', 'sidebar.missingDir': 'missing dir',
'sidebar.confirmDelete': 'Delete this session? This cannot be undone.', 'sidebar.confirmDelete': 'Delete this session? This cannot be undone.',
'sidebar.batchManage': 'Batch manage', 'sidebar.batchManage': 'Batch manage',
@ -1509,6 +1510,7 @@ export const en = {
'tabs.closeAllConfirmTitle': 'Sessions Running', 'tabs.closeAllConfirmTitle': 'Sessions Running',
'tabs.closeAllConfirmMessage': '{count} sessions are still running. Stop them before closing these tabs?', 'tabs.closeAllConfirmMessage': '{count} sessions are still running. Stop them before closing these tabs?',
'tabs.closeAllConfirmStop': 'Stop All & Close', 'tabs.closeAllConfirmStop': 'Stop All & Close',
'tabs.sessionRunning': 'Session running',
'tabs.openTerminal': 'Open Terminal', 'tabs.openTerminal': 'Open Terminal',
'tabs.showWorkspace': 'Show Workspace', 'tabs.showWorkspace': 'Show Workspace',
'tabs.hideWorkspace': 'Hide Workspace', 'tabs.hideWorkspace': 'Hide Workspace',

View File

@ -58,6 +58,7 @@ export const zh: Record<TranslationKey, string> = {
'sidebar.expandProject': '展开 {project}', 'sidebar.expandProject': '展开 {project}',
'sidebar.collapseProject': '折叠 {project}', 'sidebar.collapseProject': '折叠 {project}',
'sidebar.worktree': 'worktree', 'sidebar.worktree': 'worktree',
'sidebar.sessionRunning': '会话运行中',
'sidebar.missingDir': '目录缺失', 'sidebar.missingDir': '目录缺失',
'sidebar.confirmDelete': '确定要删除这个会话吗?此操作不可撤销。', 'sidebar.confirmDelete': '确定要删除这个会话吗?此操作不可撤销。',
'sidebar.batchManage': '批量管理', 'sidebar.batchManage': '批量管理',
@ -1511,6 +1512,7 @@ export const zh: Record<TranslationKey, string> = {
'tabs.closeAllConfirmTitle': '多个会话运行中', 'tabs.closeAllConfirmTitle': '多个会话运行中',
'tabs.closeAllConfirmMessage': '仍有 {count} 个会话正在运行。关闭这些标签前是否停止它们?', 'tabs.closeAllConfirmMessage': '仍有 {count} 个会话正在运行。关闭这些标签前是否停止它们?',
'tabs.closeAllConfirmStop': '全部停止并关闭', 'tabs.closeAllConfirmStop': '全部停止并关闭',
'tabs.sessionRunning': '会话运行中',
'tabs.openTerminal': '打开终端', 'tabs.openTerminal': '打开终端',
'tabs.showWorkspace': '显示工作区', 'tabs.showWorkspace': '显示工作区',
'tabs.hideWorkspace': '隐藏工作区', 'tabs.hideWorkspace': '隐藏工作区',