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.
This commit is contained in:
Yichen Jiang committed 2026-08-26 17:10:26 +08:00
1 parent 4edf279ed6
commit 32e9f33fd2
8 files changed
+159 -40

No files matched your search

@@ -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}}"
@@ -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
@@ -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);
@@ -268,17 +268,17 @@ function TaskItem({ row, view, expanded, now, onToggle, kill, t }: {
<span className={css.main}>
<span className={css.primary}>
<span className={css.label} title={row.label}>{row.label}</span>
{durationCell}
</span>
<span className={css.secondary}>
<span className={css.secondary} title={row.detail ?? status}>
<span className={css.kind}>{row.kind}</span>
<span className={css.status} title={row.detail ?? status}>{row.detail ?? status}</span>
{row.detail !== undefined ? <span className={css.status}>{row.detail}</span> : null}
{durationCell}
</span>
</span>
{observable
? (
<span className={css.chevronBox}>
<IconChevronDownOutline14 className={expanded ? `${css.chevron} ${css.chevronOpen}` : css.chevron} />
<IconChevronDownOutline14 size={12} className={expanded ? `${css.chevron} ${css.chevronOpen}` : css.chevron} />
</span>
)
: null}
@@ -334,7 +334,7 @@ function TaskItem({ row, view, expanded, now, onToggle, kill, t }: {
title={killTitle}
onClick={kill.onPress}
>
<IconStopFill16 size={12} />
<IconStopFill16 size={10} />
{/* The armed press must be legible without hover: the button
widens into a labeled confirm pill instead of a tint only. */}
{kill.state === 'armed' ? <span className={css.stopLabel}>{t('kill.confirmAction')}</span> : 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<string | undefined>(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<boolean | undefined>(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<ReadonlySet<string>>(() => new Set())
// One kill affordance advances at a time: arming a row disarms any other.
const [killPhase, setKillPhase] = useState<{ key: string; state: Exclude<KillState, 'idle'> } | 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<HTMLDivElement>): 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
? <li className={css.sectionHeader} aria-hidden="true">{t('section.settled')}</li>
? (
<li className={css.sectionHeader}>
<button
type="button"
className={css.sectionToggle}
aria-expanded={settledExpanded}
onClick={() => { setSettledOpen(!settledExpanded) }}
>
<IconChevronDownOutline14 size={12} className={settledExpanded ? `${css.sectionChevron} ${css.sectionChevronOpen}` : css.sectionChevron} />
{t('section.settledCount', { count: settledRows.length })}
</button>
<button type="button" className={css.sectionClear} onClick={clearSettled}>
{t('section.clear')}
</button>
</li>
)
: null}
{settledRows.map(item)}
{settledExpanded ? settledRows.map(item) : null}
</ul>
)
: null}
@@ -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<ActivityKey, string> = {
'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}',
@@ -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(<ActivityListAction {...props({}, undefined, [job(), ...settled])} />)
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(<ActivityListAction {...props({}, undefined, [job()])} />)
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(<ActivityListAction {...props({}, undefined, [
job({ status: 'completed', finishedAt: 1_700_000_012_000 }),
])} />)
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',
])
@@ -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
@@ -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}}"