diff --git a/apps/web/tests/snapshots/live-activity-stream/settled.expected.md b/apps/web/tests/snapshots/live-activity-stream/settled.expected.md index 7cf217552d..1de5fa0a36 100644 --- a/apps/web/tests/snapshots/live-activity-stream/settled.expected.md +++ b/apps/web/tests/snapshots/live-activity-stream/settled.expected.md @@ -1,4 +1,9 @@ - list "Tasks": + - listitem: + - button "Finished 1" [expanded]: + - img + - text: Finished 1 + - button "Clear" - listitem: - button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]: - text: "bash printf 'streamed-%s\\n' marker-line; sleep 45 signal: SIGTERM; web e2e cancellation {{duration}}" diff --git a/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md b/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md index 0f089d0192..762cbd58e2 100644 --- a/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md +++ b/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md @@ -1,7 +1,7 @@ - list "Tasks": - listitem: - button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]: - - text: printf 'streamed-%s\n' marker-line; sleep 45 {{duration}} bash running + - text: printf 'streamed-%s\n' marker-line; sleep 45 bash {{duration}} - img - button "Stop task printf 'streamed-%s\\n' marker-line; sleep 45": - img diff --git a/packages/client/ui-activity/src/client/ActivityListAction.module.css b/packages/client/ui-activity/src/client/ActivityListAction.module.css index 9c5bb49521..88621bc15f 100644 --- a/packages/client/ui-activity/src/client/ActivityListAction.module.css +++ b/packages/client/ui-activity/src/client/ActivityListAction.module.css @@ -64,8 +64,11 @@ /* Section labels over the live rows and the settled tail. */ .sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; margin: 4px 4px 1px; - padding: 4px 4px 3px; + padding: 4px 0 3px; border-top: 1px solid var(--dsw-alias-border-l1); color: var(--dsw-alias-label-tertiary); font-size: 11px; @@ -78,6 +81,39 @@ border-top: 0; } +/* Fold toggle and clear action on the settled section line. */ +.sectionToggle, +.sectionClear { + display: inline-flex; + gap: 3px; + align-items: center; + padding: 2px 4px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 16px; + cursor: pointer; +} + +.sectionToggle:hover, +.sectionToggle:focus-visible, +.sectionClear:hover, +.sectionClear:focus-visible { + background: var(--dsw-alias-fill-l1); + color: var(--dsw-alias-label-secondary); +} + +.sectionChevron { + transform: rotate(-90deg); + transition: transform 120ms ease; +} + +.sectionChevronOpen { + transform: none; +} + .item { display: flex; flex-direction: column; @@ -92,13 +128,18 @@ } /* Live rows read as filled cards so running work outranks the settled tail; - the trailing controls get room on the card's own inset. */ + the inner padding frames the row and both trailing controls on the fill, + and the hover wash sits on the card so the controls never escape it. */ .rowLineLive { - padding-right: 8px; + padding: 4px 8px 4px 2px; border-radius: 10px; background: var(--dsw-alias-fill-l2); } +.rowLineLive:hover { + background: color-mix(in srgb, var(--dsw-alias-label-primary) 9%, transparent); +} + /* The kind badge shares the card fill, so it pops on the menu surface instead. */ .rowLineLive .kind { background: var(--dsw-specific-menu); @@ -109,9 +150,12 @@ min-width: 0; } -/* Inside the filled card, hover darkens with a translucent wash (the flat - fill tokens vanish against the card's own fill). */ -.rowLineLive .row:hover, +/* The card carries the hover wash; the inner row stays transparent so the + two washes never stack, keeping keyboard focus its own visible state. */ +.rowLineLive .row:hover { + background: transparent; +} + .rowLineLive .row:focus-visible { background: color-mix(in srgb, var(--dsw-alias-label-primary) 6%, transparent); } @@ -122,11 +166,12 @@ display: inline-flex; flex: none; align-items: center; + align-self: center; justify-content: center; - width: 26px; - height: 26px; + width: 20px; + height: 20px; border: 1px solid var(--dsw-alias-border-l2); - border-radius: 7px; + border-radius: 6px; background: var(--dsw-specific-menu); } @@ -137,11 +182,11 @@ align-items: center; align-self: center; justify-content: center; - width: 26px; - height: 26px; + width: 20px; + height: 20px; padding: 0; border: 1px solid var(--dsw-alias-border-l2); - border-radius: 7px; + border-radius: 6px; background: var(--dsw-specific-menu); color: var(--dsw-alias-label-secondary); cursor: pointer; @@ -160,7 +205,7 @@ .stopArmed:focus-visible { gap: 4px; width: auto; - padding: 0 8px; + padding: 0 7px; border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent); background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); color: var(--dsw-alias-state-error-primary); diff --git a/packages/client/ui-activity/src/client/ActivityListAction.tsx b/packages/client/ui-activity/src/client/ActivityListAction.tsx index 515171e220..2e066c60a2 100644 --- a/packages/client/ui-activity/src/client/ActivityListAction.tsx +++ b/packages/client/ui-activity/src/client/ActivityListAction.tsx @@ -268,17 +268,17 @@ function TaskItem({ row, view, expanded, now, onToggle, kill, t }: { {row.label} - {durationCell} - + {row.kind} - {row.detail ?? status} + {row.detail !== undefined ? {row.detail} : null} + {durationCell} {observable ? ( - + ) : null} @@ -334,7 +334,7 @@ function TaskItem({ row, view, expanded, now, onToggle, kill, t }: { title={killTitle} onClick={kill.onPress} > - + {/* The armed press must be legible without hover: the button widens into a labeled confirm pill instead of a tint only. */} {kill.state === 'armed' ? {t('kill.confirmAction')} : null} @@ -386,6 +386,12 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ const observedViews = useActivity(state => state.observed) const [open, setOpen] = useState(false) const [expandedKey, setExpandedKey] = useState(undefined) + // Settled-section fold: an explicit user toggle wins; before one, the tail + // folds only while live work exists (a settled-only list opens expanded). + const [settledOpen, setSettledOpen] = useState(undefined) + // Rows the user cleared from the settled tail (client-side hide only; the + // registry keeps its records and a later settlement reappears normally). + const [clearedKeys, setClearedKeys] = useState>(() => new Set()) // One kill affordance advances at a time: arming a row disarms any other. const [killPhase, setKillPhase] = useState<{ key: string; state: Exclude } | undefined>(undefined) const [now, setNow] = useState(() => Date.now()) @@ -401,7 +407,12 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ [jobs, owned, unowned], ) const liveRows = useMemo(() => rows.filter(isLive), [rows]) - const settledRows = useMemo(() => rows.filter(row => !isLive(row)), [rows]) + const settledRows = useMemo( + () => rows.filter(row => !isLive(row) && !clearedKeys.has(row.key)), + [rows, clearedKeys], + ) + const settledExpanded = settledOpen ?? liveRows.length === 0 + const visibleCount = liveRows.length + settledRows.length useDismissOnOutsidePointer(rootRef, open, setOpen) @@ -449,11 +460,11 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ return observe(activeActivity) }, [activeActivity, observe]) - // The last task disappearing removes this control; close first so focus - // does not vanish from an unmounting node. + // The last visible task disappearing removes this control; close first so + // focus does not vanish from an unmounting node. useEffect(() => { - if (rows.length === 0 && open) setOpen(false) - }, [rows.length, open]) + if (visibleCount === 0 && open) setOpen(false) + }, [visibleCount, open]) // An expanded row that left the list (owner disposal) folds its panel. useEffect(() => { @@ -505,12 +516,23 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ }) } - if (rows.length === 0) return null + if (visibleCount === 0) return null const countKey = liveRows.length > 0 ? (liveRows.length === 1 ? 'count.live.one' : 'count.live.other') - : (rows.length === 1 ? 'count.idle.one' : 'count.idle.other') - const countLabel = t(countKey, { count: liveRows.length > 0 ? liveRows.length : rows.length }) + : (visibleCount === 1 ? 'count.idle.one' : 'count.idle.other') + const countLabel = t(countKey, { count: liveRows.length > 0 ? liveRows.length : visibleCount }) + + const clearSettled = (): void => { + setClearedKeys((current) => { + const next = new Set(current) + for (const row of settledRows) next.add(row.key) + return next + }) + if (expandedKey !== undefined && settledRows.some(row => row.key === expandedKey)) { + setExpandedKey(undefined) + } + } const onKeyDown = (event: KeyboardEvent): void => { if (event.key !== 'Escape' || !open) return @@ -570,9 +592,24 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ : null} {liveRows.map(item)} {settledRows.length > 0 - ? + ? ( +
  • + + +
  • + ) : null} - {settledRows.map(item)} + {settledExpanded ? settledRows.map(item) : null} ) : null} diff --git a/packages/client/ui-activity/src/client/locales.ts b/packages/client/ui-activity/src/client/locales.ts index 2a673f27c6..18f759670f 100644 --- a/packages/client/ui-activity/src/client/locales.ts +++ b/packages/client/ui-activity/src/client/locales.ts @@ -11,7 +11,8 @@ export const zh = { 'count.idle.other': '{count} 个任务', 'list.aria': '任务', 'section.live': '进行中', - 'section.settled': '已结束', + 'section.settledCount': '已结束 {count}', + 'section.clear': '清空', 'row.expandAria': '展开 {label} 的实时输出', 'row.collapseAria': '收起 {label} 的实时输出', 'kill.stop': '停止任务 {label}', @@ -52,7 +53,8 @@ export const en: Record = { 'count.idle.other': '{count} tasks', 'list.aria': 'Tasks', 'section.live': 'Running', - 'section.settled': 'Finished', + 'section.settledCount': 'Finished {count}', + 'section.clear': 'Clear', 'row.expandAria': 'Show live output of {label}', 'row.collapseAria': 'Hide live output of {label}', 'kill.stop': 'Stop task {label}', diff --git a/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx b/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx index 7a5572a43d..42810271f6 100644 --- a/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx +++ b/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx @@ -146,16 +146,34 @@ describe('ActivityListAction merged rows', () => { expect(screen.getByText('pnpm run build')).toBeDefined() }) - it('labels each non-empty section and drops the heading of an empty one', () => { - const settled = [job({ id: 'bash-2' as SessionJob['id'], status: 'completed', finishedAt: 1_700_000_012_000 })] + it('folds the settled tail behind its count while live work exists, and clears on demand', () => { + const settled = [job({ id: 'bash-2' as SessionJob['id'], label: 'settled work', status: 'completed', finishedAt: 1_700_000_012_000 })] const { rerender } = render() openList() expect(screen.getByText(zh['section.live'])).toBeDefined() - expect(screen.getByText(zh['section.settled'])).toBeDefined() - // A live-only list keeps its own heading and drops the settled one. + const toggle = screen.getByRole('button', { name: zh['section.settledCount'].replace('{count}', '1') }) + // Folded by default while something runs; the toggle expands it. + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByText('settled work')).toBeNull() + fireEvent.click(toggle) + expect(screen.getByText('settled work')).toBeDefined() + // Clearing hides the tail client-side and drops the whole section line. + fireEvent.click(screen.getByRole('button', { name: zh['section.clear'] })) + expect(screen.queryByText('settled work')).toBeNull() + expect(screen.queryByRole('button', { name: zh['section.clear'] })).toBeNull() + // A live-only list keeps its own heading and no settled section. rerender() expect(screen.getByText(zh['section.live'])).toBeDefined() - expect(screen.queryByText(zh['section.settled'])).toBeNull() + expect(screen.queryByRole('button', { name: zh['section.clear'] })).toBeNull() + }) + + it('opens the settled tail by default when nothing is live', () => { + render() + openList() + expect(screen.getByText('pnpm run build')).toBeDefined() + expect(screen.getByRole('button', { name: zh['section.settledCount'].replace('{count}', '1') }).getAttribute('aria-expanded')).toBe('true') }) it('shows a settled duration and ticks a live one', () => { @@ -167,6 +185,7 @@ describe('ActivityListAction merged rows', () => { job({ id: 'bash-4' as SessionJob['id'], status: 'completed', startedAt: 1_699_999_900_000, finishedAt: 1_699_999_972_000 }), ])} />) openList() + fireEvent.click(screen.getByRole('button', { name: zh['section.settledCount'].replace('{count}', '3') })) expect(screen.getByText('5秒')).toBeDefined() expect(screen.getByText('12秒')).toBeDefined() expect(screen.getByText('1小时3分')).toBeDefined() @@ -187,12 +206,16 @@ describe('ActivityListAction rows and observation', () => { }, })} />) openList() + fireEvent.click(screen.getByRole('button', { name: zh['section.settledCount'].replace('{count}', '1') })) const list = screen.getByRole('list', { name: zh['list.aria'] }) const items = within(list).getAllByRole('listitem') - expect(items).toHaveLength(2) + // The section line is a listitem too now: live row, section, settled row. + expect(items).toHaveLength(3) expect(items[0]?.textContent).toContain('pnpm run build') - expect(items[0]?.textContent).toContain(zh['status.running']) - expect(items[1]?.textContent).toContain('exit code: 3') + // A live row's second line is kind · duration; the status word only + // appears through a detail (none while running). + expect(items[0]?.textContent).toMatch(/小时|分|秒/) + expect(items[2]?.textContent).toContain('exit code: 3') }) it('marks killed rows, renders every output line unfolded, and copies the command', async () => { @@ -247,9 +270,11 @@ describe('ActivityListAction rows and observation', () => { }, })} />) openList() + fireEvent.click(screen.getByRole('button', { name: zh['section.settledCount'].replace('{count}', '5') })) const labels = within(screen.getByRole('list', { name: zh['list.aria'] })) .getAllByRole('listitem') .map(item => item.querySelector('[title]')?.getAttribute('title')) + .filter(title => title !== undefined) expect(labels).toEqual([ 'live-2', 'live-1', 'settled-d', 'settled-a', 'settled-c', 'settled-b', 'settled-e', ]) diff --git a/snapshots/web/background-job-list/running.expected.md b/snapshots/web/background-job-list/running.expected.md index 28574ac6f3..08b4d60f40 100644 --- a/snapshots/web/background-job-list/running.expected.md +++ b/snapshots/web/background-job-list/running.expected.md @@ -1,7 +1,7 @@ - list "Tasks": - listitem: - button "Show live output of sleep 45": - - text: sleep 45 {{duration}} bash running + - text: sleep 45 bash {{duration}} - img - button "Stop task sleep 45": - img diff --git a/snapshots/web/background-job-list/settled.expected.md b/snapshots/web/background-job-list/settled.expected.md index 34e39bcf26..cd05488d7f 100644 --- a/snapshots/web/background-job-list/settled.expected.md +++ b/snapshots/web/background-job-list/settled.expected.md @@ -1,4 +1,9 @@ - list "Tasks": + - listitem: + - button "Finished 1" [expanded]: + - img + - text: Finished 1 + - button "Clear" - listitem: - button "Show live output of sleep 45": - text: "bash sleep 45 signal: SIGTERM; cancelled by the user {{duration}}"