mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
fed9b6c9f6
commit
318bf336b0
@ -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'))
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '隐藏工作区',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user