From 32e9f33fd29543e43e1e88bd7ca211c6ce1c5503 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 26 Aug 2026 17:10:26 +0800 Subject: [PATCH] feat(web): tighten task-list controls, fold the settled tail, merge the live meta line Review of the first hierarchy pass in the browser: the 26px bordered controls read oversized and the card fill visually stopped short of the stop button. The controls drop to 20px squares, the live card's inner padding now frames the dot and both controls on the fill, and the hover wash moves onto the card itself so nothing escapes it. A live row's second line becomes kind + ticking duration (the redundant 'running' word only returns through a real detail). The settled tail folds behind a 'Finished N' toggle while live work exists (auto-expanded otherwise) with a client-side Clear that hides the current tail without touching the registry; goldens re-recorded for the new tree. --- .../live-activity-stream/settled.expected.md | 5 ++ .../streaming.expected.md | 2 +- .../src/client/ActivityListAction.module.css | 71 +++++++++++++++---- .../src/client/ActivityListAction.tsx | 67 +++++++++++++---- .../client/ui-activity/src/client/locales.ts | 6 +- .../activity-list-action.client.spec.tsx | 41 ++++++++--- .../background-job-list/running.expected.md | 2 +- .../background-job-list/settled.expected.md | 5 ++ 8 files changed, 159 insertions(+), 40 deletions(-) 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}}"