Skip to content

Commit f4c68e3

Browse files
fengzhi09s39-dev
andauthored
feat(webui): restore the turn chevron, phrase rotation and the uncapped group body (#120)
webui-parity 76, gaps G4 / G5 / G7 of ticket 61. Three changes that put the session surface back on the desktop's shape; none of them is a new feature. G4 — the settled turn bar carries the desktop's `>` chevron again. The gate is `hasExpandableTurnContent(stats)`, the reference's own `hasExpandableContent` question restated over the two counts the composite summary already prints, so a plain question turn keeps a bare summary line. The chevron coordinates state instead of nesting DOM: the turn's activity groups are laid out flat here, so the arrow drives them and they keep their own folding semantics. The coordination key is the turn ordinal, not the unit index, because activity runs are re-cut every streaming frame. G5 — the streaming indicator rotates through the desktop's phrase table: 2000–3000 ms before the first swap, 3500 ms after, a weighted bucket draw (basic 0.75 / specific 0.15 / motion 0.1) with the previous phrase filtered out. The phase label the engine reported stays on screen until the first swap. Under prefers-reduced-motion the rotation continues: a text swap is not motion, the desktop makes the same trade, and stopping would reinstall the frozen-label defect. The animated half still switches off through the existing globals.css rules. G7 — the activity group body loses its 230px cap and its nested scrollbar, and takes the reference's zero gap plus a 28px row floor. The transcript column already scrolls, and a second scroller cut a long tool run's own steps off mid-list. Removing the cap costs no render work: a collapsed <details> keeps its body in the DOM either way. The tool detail's `pre` drops from 320px to the reference's 180px, the one scroll container the desktop keeps. Tests: three new suites (57 cases) plus one updated call-site assertion, and nine mutations verified red-then-restored byte-for-byte. Co-authored-by: s39-dev <s39-dev@local>
1 parent 37beaf2 commit f4c68e3

13 files changed

Lines changed: 1761 additions & 43 deletions

‎docs/webui.md‎

Lines changed: 103 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,8 @@ below cites the component file and one `data-testid` per surface.
477477
| Composer + drop overlay | `components/composer.tsx` | `composer-drop-overlay`, `composer-send-button` |
478478
| Chat (virtual list ≥ 200 messages) | `components/chat.tsx` + `chat-virtual-list.tsx` | `chat-virtual-top-spacer` |
479479
| Turn process bar (composite summary + output rate since ticket 46 PR3) | `components/activity-group.tsx#TurnProcessDisclosure` | `turn-process-disclosure` |
480+
| Turn-bar expand chevron (webui-parity 61, drives the turn's activity groups) | `components/activity-group.tsx#TurnProcessDisclosure` | `turn-process-trigger`, `turn-process-chevron` |
481+
| Streaming-label phrase rotation (webui-parity 61) | `components/loading-states.tsx#ActivityPulse` + `lib/thinking-phrases.ts` | `activity-indicator-label` |
480482
| Activity group (collapsible tool turns; in `activity-group.tsx` since ticket 46) | `components/activity-group.tsx` | `activity-group-header` |
481483
| Thinking block (thought-process disclosure row, ticket 46 PR2) | `components/activity-group.tsx` | `thinking-block` |
482484
| Tool card (one tool call, ticket 46 PR3) | `components/activity-group.tsx#ToolCard` | `tool-card` |
@@ -1304,6 +1306,20 @@ reads like "Thought 1 time, ran 1 command"):
13041306
AND tools) opens expanded; a pure-tool run starts collapsed; thinking
13051307
rows nested in a mixed run start collapsed while a thoughts-only run
13061308
starts with its thinking row expanded.
1309+
- **The group body is uncapped and has no scrollbar of its own (webui-parity
1310+
61, restoring the desktop shape)**: the body used to carry
1311+
`max-h-[230px] overflow-y-auto`, so a long tool run nested a second
1312+
scrollbar inside a page that already scrolls and cut its own steps off
1313+
mid-list. The reference stylesheet gives `.activity-group-items`
1314+
`gap: 0` plus a 28px minimum row height and no `max-height`; this
1315+
repository now matches — zero gap as the component's `gap-0`, and the
1316+
28px floor in `app/globals.css` as `.activity-group-items > *` (outside
1317+
every `@layer`, so Tailwind's purge cannot reach a hand-written rule).
1318+
Removing the cap costs no render work either way: a collapsed
1319+
`<details>` keeps its body in the DOM, so the row count was never the
1320+
reason for the cap. The one scroll container the desktop keeps inside a
1321+
group is the tool detail's `pre`, capped at 180px (the reference
1322+
`.webui-tool-detail-section pre`); this repository shipped 320px.
13071323

13081324
**Thinking block** (one thought):
13091325

@@ -1464,12 +1480,57 @@ While a turn is in flight, a process bar renders at the transcript tail
14641480
four server files; fixing it (persisting the marker, or moving it to
14651481
a structured field) needs its own ticket — until then the settled bar
14661482
is transient by design of the transport.
1467-
- A 0.5px separator closes the bar from below. The retired
1468-
expand-to-repeat-the-same-sentence interaction is gone (there is no
1469-
content to expand — the reference renders a plain summary row when
1470-
the turn has no expandable content of its own); the
1483+
- A 0.5px separator closes the bar from below; the
14711484
`turn-process-disclosure` testid is kept.
14721485

1486+
#### The turn bar's expand chevron (webui-parity 61, restoring the desktop shape)
1487+
1488+
A settled turn bar carries the desktop's `>` chevron
1489+
(`turn-process-chevron`). Ticket 46 removed it on the grounds that there
1490+
was "nothing to expand" — this repository lays the thinking and tool
1491+
steps out flat as activity groups instead of nesting them inside the
1492+
turn bar's collapsible region, so the chevron had nothing to point at.
1493+
Webui-parity 61 changes that premise: the chevron is **state
1494+
coordination** that drives the turn's activity groups, without
1495+
re-arranging the DOM.
1496+
1497+
- **When it appears**: the turn has thoughts or tool calls. The predicate
1498+
is the exported pure function `hasExpandableTurnContent(stats)` — the
1499+
same two counts the composite summary already prints, and the same
1500+
question the reference asks through `WebuiTurnProcess`'s
1501+
`hasExpandableContent` (which `AssistantBody.tsx` computes from that
1502+
turn's thinking text, tools and activity segments). A plain question
1503+
turn (no thought, no tool) has nothing to disclose and renders the bare
1504+
summary line with no chevron; that is the desktop's own rule, and the
1505+
reason the turn in desktop screenshot 02 does show one is that the turn
1506+
itself had process content.
1507+
- **A live turn never shows it.** The reference reaches the same state
1508+
through its `forceExpanded` / `disabled` props, whose docblock says
1509+
those modes "suppress the toggle and keep available details open". Here
1510+
there is a harder reason as well: an activity group holding a running
1511+
tool is force-open under `data-active` and cannot be collapsed, so a
1512+
live chevron would be a control that cannot act.
1513+
- **What expanding shows**: the turn's thinking and tool steps — the
1514+
activity groups. The turn's answer text is not part of it: the
1515+
reference keeps the answer outside the collapse through
1516+
`collapsedContent`, and here the answer is never inside a group.
1517+
- **The coordination key is the turn ordinal, not the unit index.**
1518+
Activity runs are re-cut on every streaming frame, so a unit index
1519+
drifts while its turn ordinal only advances when a new user prompt
1520+
arrives; keying on units would detach the chevron from the groups it
1521+
drives the moment a tool header lands. `computeTurnLayout` in
1522+
`webapp/lib/turn-stats.ts` is the pure function that produces the
1523+
ordinals, and the intent lives in one
1524+
`Map<turn ordinal, expanded>` in `Chat`.
1525+
- **Until the user clicks, every group keeps its own default** (mixed run
1526+
open, pure-tool run collapsed). The chevron's `aria-expanded` reads
1527+
`computeTurnLayout#defaultExpandedByTurn` (true when any group of the
1528+
turn opens by default) and inverts it on the first click, so the first
1529+
click always changes something visible. After that the turn's groups
1530+
move as one block.
1531+
- Neither the `data-active` forced-open rule nor a group's own folding
1532+
semantics change: a collapse intent never overrides forced open.
1533+
14731534
## Loading states: transcript skeleton and streaming indicator (ticket U8)
14741535

14751536
The two waiting windows on the conversation surface have distinct treatments,
@@ -1478,7 +1539,7 @@ and neither is a bare spinner:
14781539
| Waiting for | What renders | Where it lives |
14791540
| --- | --- | --- |
14801541
| The first state snapshot (page load, engine boot) | `TranscriptSkeleton` — a shimmer placeholder shaped like the real transcript: right-aligned user bubbles, full-width assistant text lines, a tool-summary row with indented output lines. The connection copy (connecting / disconnected) stays underneath | The page-level `!state` branch in `app/page.tsx`; the component itself is `webapp/components/loading-states.tsx` |
1481-
| The current turn's output (`running.active`) | `ActivityPulse` at the transcript tail — the desktop's three-dot loader plus a shimmer bar sitting where the next line of output will land, next to the phase label (thinking / working / …) | `components/chat.tsx#ThinkingIndicator`, gated by the exported pure function `isSessionActivityActive` |
1542+
| The current turn's output (`running.active`) | `ActivityPulse` at the transcript tail — the desktop's three-dot loader plus a shimmer bar sitting where the next line of output will land. The label shows the phase the engine reported (thinking / working / …) first, then rotates through the desktop's weighted phrase table | `components/chat.tsx#ThinkingIndicator`, gated by the exported pure function `isSessionActivityActive` |
14821543

14831544
Invariants worth keeping when touching either branch:
14841545

@@ -1501,13 +1562,47 @@ Invariants worth keeping when touching either branch:
15011562
- Rendering tests for both components and the reduced-motion tripwire live in
15021563
`webapp/test/loading-skeleton.test.ts` (SSR through
15031564
`renderToStaticMarkup`; the suite has no DOM harness).
1565+
- **Streaming-label phrase rotation (webui-parity 61, restoring the desktop
1566+
shape)**: the desktop does not park one static label on screen for the
1567+
length of a turn. The schedule and the draw are transcribed from the
1568+
reference `ActivityIndicator.tsx`: 2000–3000 ms (drawn) before the first
1569+
swap, 3500 ms between swaps after that, a weighted bucket draw — basic
1570+
0.75, specific 0.15, motion 0.1 — uniform inside the drawn bucket, with
1571+
the previous phrase filtered out so nothing repeats back to back. The
1572+
tables are in `webapp/lib/thinking-phrases.ts`, indexed by `Locale` with
1573+
the same structure in both languages; a separate module rather than the
1574+
flat `lib/i18n.ts` dictionary, for the reason `lib/i18n-agent-team.ts`
1575+
gives. The draw and the schedule are the exported pure functions
1576+
`pickWeightedPhrase` / `computeThinkingPhraseStartDelay`, and the timer
1577+
lives in `ActivityPulse` alone.
1578+
- **Under `prefers-reduced-motion` the rotation continues, deliberately.** A
1579+
phrase swap is a discrete text replacement, not motion: there is no
1580+
translation, no scaling and no continuous movement for a vestibular
1581+
trigger to react to, and the desktop makes the same trade — its
1582+
reduced-motion branch halts the lottie and leaves the label ticking.
1583+
Stopping the rotation would re-install the frozen-label defect G5 exists
1584+
to remove. The animated half of the indicator (three dots, shimmer) is
1585+
still switched off by the explicit `globals.css` rules above, so a
1586+
reduce-motion user sees a still indicator with a still-ticking label.
1587+
- **No side effects on streaming**: the rotation's state lives in
1588+
`ActivityPulse` itself, so a swap re-renders that one `<span>`
1589+
(`activity-indicator-label`) and nothing above it — the transcript
1590+
bodies, the markdown and the streaming cursor are not in the update path.
1591+
The effect depends on the phrase table alone (a module-level constant
1592+
looked up by locale, stable identity), never on a per-render closure and
1593+
never on `label` — the phase copy changes with the engine, and depending
1594+
on it would reset the schedule mid-turn. The timer is a chained
1595+
`setTimeout` rather than a `setInterval` and is cleared on cleanup; the
1596+
component unmounts when the turn settles, leaving no pending timer.
1597+
Effects do not run on the server, so the SSR and hydration first frames
1598+
are both the phase copy — no first-paint swap.
15041599
- Boundary with ticket 46: while a thought streams, the 「推理中...」+
15051600
ticking-seconds readout lives on the thinking block's summary row inside
15061601
the tail activity group, and the live 「已执行 N 秒」 turn bar sits at
15071602
the transcript tail (see the "Session rendering" section above); the
1508-
`ActivityPulse` in this table (three dots + shimmer + phase label)
1509-
also appears only at the transcript tail, below the turn bar.
1510-
Different positions, different jobs; none replaces another.
1603+
`ActivityPulse` in this table (three dots + shimmer + phase label +
1604+
rotating phrases) also appears only at the transcript tail, below the
1605+
turn bar. Different positions, different jobs; none replaces another.
15111606

15121607
## Persistence keys (client-side `localStorage` / `sessionStorage`)
15131608

0 commit comments

Comments
 (0)