.agents/skills/ux-audit/references/example/fleet.md
/fleet) auditA real run of this skill against the Fleet view ("Observation Mode"), 2026-07 — a side-by-side, drag-reorderable column board of running agent tasks: a left running-task sidebar portaled into the global NavPanel + a horizontally-scrolling board where each running topic is an independently-scrollable, resizable conversation column, auto-populated from a live poll of the account's running topics. Use it as a template for the output shape, not as current-state truth (the code moves; re-verify before citing).
Surface class: live multi-column dashboard — benchmark against TweetDeck / column dashboards, CI run boards (GitHub Actions), and Kanban boards (Trello). Class norms: each column + the board have empty/loading/error states; a live feed signals new items and offers manual refresh; add / remove / reorder / persist columns; a "keep this column" marker actually protects it. Layers run: L1 (static / code) ✅ — everything below. L2 (visual) / L3 (dynamic + CLS) ⏳ not yet run — see §5. Verdicts about the render are L1 inferences here, pending L2.
Load-bearing files: routes/(main)/fleet/index.tsx, features/Fleet/{index, ColumnsBoard, AgentColumn, RunningTaskSidebar, AddColumnButton, RowsSwitcher, FleetPanelCollapseSync}.tsx,
features/Fleet/{store, useRunningTopics, idleColumns, runningStatus, types}.ts,
libs/swr/index.ts (useClientDataSWR — non-suspense).
| Pattern (family) | Where | Rating | Note |
|---|---|---|---|
| Movable Panels / Card Stack (layout) | ColumnsBoard dnd-kit columns + bands (ColumnsBoard.tsx) | ✅ | reorder + cross-band drag, drag overlay preview |
| Overview + Detail (data) | sidebar running list → board column (RunningTaskSidebar) | ✅ | click an item opens/focuses its column |
| Deep-linking (nav) | /fleet, /:workspaceSlug/fleet | ✅ | route restores the board (columns from persist) |
| Movable Panels persistence (layout) | store.ts persist (columns/widths/rows/pins) | ✅ | layout survives reload — highlight |
| News/Activity Stream (data) | useRunningTopics focus-poll (focusThrottleInterval: 1000) | ⚠️ | no new-item signal, no manual refresh (gap ④) |
| Loading Skeleton (feedback) | SidebarTaskSkeleton (mirrors the row), page BrandTextLoading | ✅ | skeleton reuses row chrome — good |
| Failure + Retry (feedback) | running-topics poll | — abs. | error dropped entirely — the dominant gap (①) |
| Empty state — board (data) | ColumnsBoard.tsx:151-163 (Layers icon + title + desc) | ⚠️ | real text, CTA is a bare 52px "+" (gap ⑤) |
| Empty state — sidebar (data) | RunningTaskSidebar.tsx:284 fleet.noRunningTasks | ⚠️ | plain text; doubles as the failure state (①) |
| Selection-into-view (data) | handleActivate / AddColumnButton double-rAF scrollIntoView | ✅ | new/focused column scrolls in — §1.3 done right |
| Cancelability / Progress (feedback) | RunningStatus live elapsed clock + ring; StatusDot | ✅ | per-task elapsed time — highlight |
| Smart Menu Items (commands) | CloseIdleColumnsButton disabled/counted by idle set | ⚠️ | ignores pins; unsafe on error (gaps ②③) |
| Pin / keep marker (commands) | AgentColumn pin ActionIcon + pinnedKeys | ⚠️ | purely cosmetic — protects nothing (gap ③) |
Read: the board's layout/persistence/drag machinery and per-task progress (live elapsed clock, scroll-into-view) are mature and a highlight. The weakness clusters exactly where every audit so far has hit — Feedback (the poll can't fail) and Read (a failed / unloaded live feed masquerading as "empty/idle") — and here that soft spot feeds a destructive bulk action, which is what makes ① + ② a trust break rather than cosmetic.
The board is strong where it counts — these are the ✅ half of the 回灌 loop and the "don't
regress" list for the next refactor. Two are strong enough to land as ✅ examples in the ux
checklists (see §4):
overflow clipping),
ColumnsBoard.tsx + ColumnDragPreview, over a persist store that survives reload —
columns / widths / rows / pins (store.ts partialize). Movable-Panels done to spec.RunningStatus's live elapsed clock + ring
(RunningTaskSidebar.tsx:47-107) reads the runtime's real visible-start time and even hides
the spinner in the visible_output_end tail (runningStatus.ts), so each column honestly
shows how long its run has worked rather than a static "running" label.handleActivate /
AddColumnButton scroll a (re-)opened / newly-added column into the horizontal band via a
double-requestAnimationFrame scrollIntoView({ block: 'nearest', inline: 'end' }),
mirrored across both entry points so neither can regress alone.SidebarTaskSkeleton mirrors SidebarTaskItem exactly (28 px square avatar + two text lines
at 70 % / 45 % width, same padding), an in-place load→content swap with no relayout.ReplyArea reads the conversation's real loaded state
(messagesInit) rather than the raw dbMessagesMap (where undefined = "still loading"), so
the reply affordance never blanks out mid-load — a stable "回复" placeholder holds the slot
(AgentColumn.tsx:255-292). A quiet correctness win worth keeping.① Running-topics poll swallows error → false "no running tasks" + every status collapses to idle + no retry — Read §1.1 / §1.7, Feedback §4.2 🔴
useRunningTopics destructures only { data, isLoading } and drops error
(useRunningTopics.ts:36); isInit: !isLoading (:61). useClientDataSWR is not suspense
(libs/swr/index.ts:22-52), so a poll error is never thrown to the route <ErrorBoundary> and
never represented. On error SWR sets error + flips isLoading false → isInit becomes
true with data === undefined → columns = [], statusByColumnKey = {}. Consequences:
fleet.noRunningTasks (RunningTaskSidebar.tsx:281-284, since
isLoading = !isInit = false) — a failed poll is indistinguishable from "no running
tasks", the data ?? [] → empty trap on the read side of a live feed (§1.7: a failed
refresh must never look like "no new items").StatusDot / elapsed clock reads the empty
statusByColumnKey → all columns silently collapse to idle; the live error is invisible
on the board entirely.error; drive a distinct failed state (sidebar + board
banner) with a Reload that re-runs the SWR mutate; keep isInit gated so
"errored/unknown" ≠ "loaded-empty".② "Close idle columns" becomes a one-click board-wiper on poll error — Act §3.4 / Feedback §4.2 (blast radius of ①) 🟠
getIdleColumnKeys marks every column whose statusByColumnKey[key] !== 'running' as idle
(idleColumns.ts:18). It gates on isStatusLoading — but that's wired to isLoading = !isInit
(RunningTaskSidebar.tsx:266-268), which is false on error (see ①). So when the poll fails
the status map is {}, nothing loads as running, and every open column reads as idle — the
button lights up "close N idle columns" and one click removes the whole board
(removeColumns(idleKeys), :208). A destructive bulk action must treat "unknown/errored"
status as not eligible, not as idle: gate on the query's real error/loading, never on a
success-only init flag.
③ Pin is decorative — it protects nothing, and "close idle" overrides it — Act §3.4 🟠
pinnedKeys is only toggled (togglePin) and filtered out on removal; no code path gates on
it — syncRunningColumns is append-only (never auto-removes) and getIdleColumnKeys never
consults pins. So the pin ActionIcon (AgentColumn.tsx:422-428, titled "pin / keep this
column") merely highlights an icon: a "keep this" affordance that keeps nothing, and worse,
"close idle" (gap ②) will happily close a pinned-but-idle column — directly contradicting the
store's own comment that a pin is "a deliberate 'keep this column' marker" (store.ts:47). Fix:
either honor the pin (exclude pinnedKeys from getIdleColumnKeys / any future auto-cleanup) or
remove the affordance; a protective marker must be honored by every removal path.
④ Live board has no new-item signal and no manual refresh — Read §1.7 🟡
The board is an explicit polling feed (focusThrottleInterval: 1000, refetch on focus) and
syncRunningColumns silently auto-appends each newly-running topic as a column to the
least-full band (store.ts:176-201) — often off the right edge of the horizontal scroll. There's
no "N new" indicator that a column appeared beyond the fold, and no manual refresh control
(the user is hostage to focus + the 1 s throttle). Class norm for a TweetDeck-style board; L1 can
name it, L2 confirms how invisible the off-screen append reads.
⑤ Board empty state has value-prop text but no clear CTA button — Read §1.1 🟡 (pending L2)
The board empty renders an icon + fleet.empty + fleet.emptyDesc
(ColumnsBoard.tsx:151-163) — real explanation — but the only add affordance is the bare 52 px
AddColumnButton "+" strip beside it (AddColumnButton.tsx:85-90), not a labeled "Add a column /
Create task" CTA in the empty region. Whether the "+" reads as the next action is an L2
verdict — confirm on the render.
The 回灌 loop has two halves — a gap sharpens a checklist item's ❌ example, a good case sharpens its ✅ one. Both landed from this run:
scrollIntoView fires before React commits the
node); and (b) the scroll axis follows the list direction — inline: 'nearest'/'end'
for a horizontal band, not just block. Both folded into the prose + checklist; Fleet is
the ✅ example.SidebarTaskSkeleton extracted "match the real text's width
proportion, not just its height" (long title line over a shorter subtitle, not two equal
bars) — the rule previously said only "height ≈ real". §4.1 also had no ✅ example before.isInit: !isLoading, gap ①), Read §1.1 empty-needs-CTA
(gap ⑤).L1-only; a later pass should confirm / quantify: