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.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(<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 () => {
createSession.mockRejectedValue(new Error('boom'))

View File

@ -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<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(
() => (pendingBatchDeleteSessionIds ?? [])
.map((sessionId) => sessionsById.get(sessionId))
@ -986,14 +998,12 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
{t('sidebar.missingDir')}
</span>
)}
{isWorktreeSession(session) && (
<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)]">
{t('sidebar.worktree')}
</span>
)}
<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>
<SessionRowMeta
isRunning={runningSessionIds.has(session.id)}
isWorktree={isWorktreeSession(session)}
modifiedAt={session.modifiedAt}
t={t}
/>
</span>
</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({
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 | 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)
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() {

View File

@ -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<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 {
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<string, HTMLDivElement | null>())
const startDraggingRef = useRef<(() => Promise<void>) | null>(null)
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(() => {
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<HTMLDivElement, {
tab: Tab
isRunning: boolean
isActive: boolean
isDragOver: boolean
isDragging: boolean
dragOffsetX: number
runningLabel: string
onClick: () => 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 (
<div
ref={ref}
@ -506,8 +521,12 @@ const TabItem = forwardRef<HTMLDivElement, {
transform: isDragging ? `translateX(${dragOffsetX}px) scale(1.02)` : undefined,
}}
>
{tab.type === 'session' && tab.status === 'running' && (
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)] animate-pulse flex-shrink-0" />
{tab.type === 'session' && isRunning && (
<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' && (
<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.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',

View File

@ -58,6 +58,7 @@ export const zh: Record<TranslationKey, string> = {
'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<TranslationKey, string> = {
'tabs.closeAllConfirmTitle': '多个会话运行中',
'tabs.closeAllConfirmMessage': '仍有 {count} 个会话正在运行。关闭这些标签前是否停止它们?',
'tabs.closeAllConfirmStop': '全部停止并关闭',
'tabs.sessionRunning': '会话运行中',
'tabs.openTerminal': '打开终端',
'tabs.showWorkspace': '显示工作区',
'tabs.hideWorkspace': '隐藏工作区',