From 0725fabe84e7a047e44bd98c10ed10da95826c27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E7=9B=9B=E5=8D=8E?= <736139669@qq.com> Date: Mon, 3 Aug 2026 09:24:44 +0800 Subject: [PATCH] feat(web): pin running sessions in an in-progress section with state badges (#1315) * feat(web): pin running sessions in an 'in progress' section with a live badge * feat(web): show project name on pinned running session rows * feat(web): make the pinned 'in progress' section collapsible * fix(web): don't auto-expand directory groups when opening pinned running sessions * fix(web): keep running section open while searching; clear auto-expand guard when selection leaves a group * feat(web): show machine label on pinned running session rows * fix(web): make running-section toggle keyboard-accessible with correct filtered state * feat(web): split pinned running section into working/pending/idle groups with distinct badges --- bun.lock | 2 + .../SessionList.directory-action.test.tsx | 83 ++++++++++-- web/src/components/SessionList.tsx | 126 +++++++++++++++++- web/src/components/SessionRowSummary.tsx | 62 ++++++++- web/src/lib/locales/en.ts | 3 + web/src/lib/locales/zh-CN.ts | 3 + 6 files changed, 263 insertions(+), 16 deletions(-) diff --git a/bun.lock b/bun.lock index a25ae81b..aa0bf7d9 100644 --- a/bun.lock +++ b/bun.lock @@ -1096,6 +1096,8 @@ "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.25.3", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-mLe7mJKctdEIeMmf19u0wWcpf4Lj7sA/SJT65cd+4G5wWRy632SAPcJ7pl+KSdsbEu9TMTyrXm1L4usB0xc4Gg=="], + "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.25.3", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-K1zhYTj8eAPt5W7q92U4ywdUs0122faulU2kywHOuzmwO95nI4zQ0CXlCNkJlGpTv6AYj7GiQdU21sR1NJSYFg=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], diff --git a/web/src/components/SessionList.directory-action.test.tsx b/web/src/components/SessionList.directory-action.test.tsx index 9633c583..bcedec27 100644 --- a/web/src/components/SessionList.directory-action.test.tsx +++ b/web/src/components/SessionList.directory-action.test.tsx @@ -388,9 +388,9 @@ describe('SessionList collapse behavior', () => { ] const { rerender } = render(renderSessionList(baseSessions)) - expect(getProjectPanel().getAttribute('data-open')).toBe('true') - - fireEvent.click(screen.getByTitle('/work/hapi')) + // The running session is pinned in the "in progress" section; the + // directory group now only holds inactive sessions and starts + // collapsed. expect(getProjectPanel().getAttribute('data-open')).toBeNull() rerender(renderSessionList([ @@ -410,9 +410,7 @@ describe('SessionList collapse behavior', () => { it('auto-expands the path again when the selected session changes', async () => { const sessions = [ makeSession({ - id: 'session-running', - active: true, - thinking: true, + id: 'session-first', updatedAt: 100, metadata: { path: '/work/hapi', name: 'Running task', flavor: 'codex' }, }), @@ -424,16 +422,85 @@ describe('SessionList collapse behavior', () => { ] const { rerender } = render(renderSessionList(sessions)) - fireEvent.click(screen.getByTitle('/work/hapi')) + // Inactive-only groups start collapsed; selecting a session inside + // one auto-expands it. expect(getProjectPanel().getAttribute('data-open')).toBeNull() - rerender(renderSessionList(sessions, 'session-next')) + rerender(renderSessionList([ + ...sessions, + makeSession({ + id: 'session-running', + active: true, + thinking: true, + updatedAt: 110, + metadata: { path: '/work/hapi', name: 'Running task', flavor: 'codex' }, + }), + ], 'session-next')) await waitFor(() => { expect(getProjectPanel().getAttribute('data-open')).toBe('true') }) }) + it('keeps the running section open while searching even when collapsed', () => { + const sessions = [ + makeSession({ + id: 'session-running', + active: true, + thinking: true, + updatedAt: 100, + metadata: { path: '/work/hapi', name: 'Running task', flavor: 'codex' }, + }), + makeSession({ + id: 'session-idle', + updatedAt: 50, + metadata: { path: '/work/hapi', name: 'Idle task', flavor: 'codex' }, + }), + ] + render(renderSessionList(sessions)) + + const runningPanel = () => screen.getByTitle('In progress').nextElementSibling + + expect(runningPanel()?.getAttribute('data-open')).toBe('true') + expect(screen.getByTitle('In progress').getAttribute('aria-expanded')).toBe('true') + + fireEvent.click(screen.getByTitle('In progress')) + expect(runningPanel()?.getAttribute('data-open')).toBeNull() + expect(screen.getByTitle('In progress').getAttribute('aria-expanded')).toBe('false') + + fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) + fireEvent.change(screen.getByPlaceholderText('Search sessions…'), { + target: { value: 'Running' }, + }) + + expect(runningPanel()?.getAttribute('data-open')).toBe('true') + // The section stays reported open while searching even though the + // underlying collapsed state is still set. + expect(screen.getByTitle('In progress').getAttribute('aria-expanded')).toBe('true') + }) + + it('toggles the running section with the keyboard', () => { + const sessions = [ + makeSession({ + id: 'session-running', + active: true, + thinking: true, + updatedAt: 100, + metadata: { path: '/work/hapi', name: 'Running task', flavor: 'codex' }, + }), + ] + render(renderSessionList(sessions)) + + const header = screen.getByRole('button', { name: /In progress/ }) + expect(header.getAttribute('aria-expanded')).toBe('true') + + fireEvent.keyDown(header, { key: 'Enter' }) + expect(header.getAttribute('aria-expanded')).toBe('false') + + fireEvent.keyDown(header, { key: ' ' }) + expect(header.getAttribute('aria-expanded')).toBe('true') + }) + it('keeps the previous selected path open when selection moves', async () => { const sessions = [ makeSession({ diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index d27ed862..f38e4aaf 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -41,6 +41,12 @@ type SessionGroup = { hasActiveSession: boolean } +const RUNNING_BUCKETS = [ + { key: 'working', labelKey: 'session.item.running', colorClass: 'text-[var(--app-badge-success-text)]', pulse: true }, + { key: 'pending', labelKey: 'session.item.pending', colorClass: 'text-[var(--app-badge-warning-text)]', pulse: true }, + { key: 'idle', labelKey: 'session.item.idle', colorClass: 'text-[var(--app-hint)]', pulse: false }, +] as const + export type SessionTimeRange = { start: number | null end: number | null @@ -764,9 +770,12 @@ function SessionItem(props: { api: ApiClient | null selected?: boolean showDetailedStatus?: boolean + inRunningSection?: boolean + projectLabel?: string + machineLabel?: string }) { const { t } = useTranslation() - const { session: s, onSelect, showPath = true, api, selected = false, showDetailedStatus = false } = props + const { session: s, onSelect, showPath = true, api, selected = false, showDetailedStatus = false, inRunningSection = false, projectLabel, machineLabel } = props const { haptic } = usePlatform() const [menuOpen, setMenuOpen] = useState(false) const [menuAnchorPoint, setMenuAnchorPoint] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) @@ -859,6 +868,9 @@ function SessionItem(props: { nestedTooltips attentionTooltipId={attentionId} scheduleTooltipId={scheduleId} + inRunningSection={inRunningSection} + projectLabel={projectLabel} + machineLabel={machineLabel} /> @@ -1066,13 +1078,41 @@ export function SessionList(props: { : visibleSessions.filter(session => (session.metadata?.machineId ?? UNKNOWN_MACHINE_ID) === activeMachineFilter), [visibleSessions, activeMachineFilter] ) + const runningSessions = useMemo(() => { + const buckets: Record<'working' | 'pending' | 'idle', SessionSummary[]> = { + working: [], + pending: [], + idle: [] + } + for (const session of machineFilteredSessions) { + if (!session.active) { + continue + } + if (session.thinking || (session.backgroundTaskCount ?? 0) > 0) { + buckets.working.push(session) + } else if ((session.pendingRequestsCount ?? 0) > 0) { + buckets.pending.push(session) + } else { + buckets.idle.push(session) + } + } + const byRecent = (a: SessionSummary, b: SessionSummary) => b.updatedAt - a.updatedAt + for (const key of Object.keys(buckets) as Array) { + buckets[key].sort(byRecent) + } + return buckets + }, [machineFilteredSessions]) + const runningSessionTotal = runningSessions.working.length + + runningSessions.pending.length + + runningSessions.idle.length const groups = useMemo( - () => groupSessionsByDirectory(machineFilteredSessions), + () => groupSessionsByDirectory(machineFilteredSessions.filter((session) => !session.active)), [machineFilteredSessions] ) const [collapseOverrides, setCollapseOverrides] = useState>( () => new Map() ) + const [runningSectionCollapsed, setRunningSectionCollapsed] = useState(false) const autoExpandedSelectedSessionKeyRef = useRef(null) const isGroupCollapsed = (group: SessionGroup): boolean => { if (isFiltering) return false @@ -1159,17 +1199,28 @@ export function SessionList(props: { return } - const group = allGroups.find(g => + // Pinned "in progress" sessions are not rendered inside directory + // groups, so only auto-expand when the selected session actually lives + // in a visible group. Using `allGroups` here would expand the group + // below whenever a running session is opened. + const group = groups.find(g => g.sessions.some(s => s.id === selectedSessionId) ) - if (!group) return + if (!group) { + // The selected session is not rendered inside any directory group + // (e.g. it moved to the pinned "in progress" section). Drop the + // guard so it auto-expands again when it transitions back into a + // group later. + autoExpandedSelectedSessionKeyRef.current = null + return + } const autoExpandKey = `${selectedSessionId}::${group.key}` if (autoExpandedSelectedSessionKeyRef.current === autoExpandKey) return autoExpandedSelectedSessionKeyRef.current = autoExpandKey setCollapseOverrides(prev => expandSelectedSessionCollapseOverrides(prev, group)) - }, [selectedSessionId, allGroups]) + }, [selectedSessionId, groups]) // Clean up stale collapse overrides useEffect(() => { @@ -1393,12 +1444,75 @@ export function SessionList(props: { /> ) : null} - {props.sessions.length > 0 && (isFiltering || activeMachineFilter !== null) && groups.length === 0 ? ( + {props.sessions.length > 0 && (isFiltering || activeMachineFilter !== null) && groups.length === 0 && runningSessionTotal === 0 ? (
{t('sessions.search.noResults')}
) : null} + {runningSessionTotal > 0 ? ( +
+
setRunningSectionCollapsed((value) => !value)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + setRunningSectionCollapsed((value) => !value) + } + }} + title={t('sessions.runningSection')} + > + +
+
+
+
+ {RUNNING_BUCKETS.map((bucket) => { + const sessions = runningSessions[bucket.key] + if (sessions.length === 0) { + return null + } + return ( +
+
+
+ {sessions.map((s) => ( + + ))} +
+ ) + })} +
+
+
+
+ ) : null} {groups.map((group) => { const isCollapsed = isGroupCollapsed(group) const visibleGroupSessions = getVisibleGroupSessions(group) diff --git a/web/src/components/SessionRowSummary.tsx b/web/src/components/SessionRowSummary.tsx index df94b8bd..73fcfd6d 100644 --- a/web/src/components/SessionRowSummary.tsx +++ b/web/src/components/SessionRowSummary.tsx @@ -111,6 +111,12 @@ export function SessionRowSummary(props: { attentionTooltipId?: string scheduleTooltipId?: string className?: string + /** Rows inside the pinned "in progress" section skip the text label (dot only). */ + inRunningSection?: boolean + /** Short project name shown under the title (pinned "in progress" rows). */ + projectLabel?: string + /** Machine label shown next to the project name (pinned "in progress" rows). */ + machineLabel?: string }) { const { session: s, @@ -121,6 +127,9 @@ export function SessionRowSummary(props: { attentionTooltipId: attentionTooltipIdProp, scheduleTooltipId: scheduleTooltipIdProp, className, + inRunningSection = false, + projectLabel, + machineLabel, } = props const { t } = useTranslation() const sessionName = getSessionTitle(s) @@ -136,6 +145,8 @@ export function SessionRowSummary(props: { [s, selected, showDetailedStatus] ) const attentionLabel = attention ? getAttentionLabel(attention, t) : null + const urgentAttention = attention !== null + && (attention.kind === 'permission' || attention.kind === 'input') const scheduledLabel = s.futureScheduledMessageCount > 1 ? t('session.item.scheduledMessages', { count: s.futureScheduledMessageCount }) : t('session.item.scheduledMessage') @@ -160,7 +171,50 @@ export function SessionRowSummary(props: { {sessionName} {s.active && s.thinking ? ( - + + ) : urgentAttention && nestedTooltips && attentionId ? ( + + ) : urgentAttention ? ( + + ) : s.active && (s.backgroundTaskCount ?? 0) > 0 ? ( + + + ) : s.active && (s.pendingRequestsCount ?? 0) > 0 ? ( + + + ) : s.active ? ( + + ) : attention && nestedTooltips && attentionId ? ( - {showPath || worktreeLabel ? ( + {projectLabel || machineLabel ? ( +
+ {[projectLabel, machineLabel].filter(Boolean).join(' · ')} +
+ ) : showPath || worktreeLabel ? (