Skip to content

refactor(session): rewrite session view per W1 visual lock for slice 11b.1#589

Closed
Astro-Han wants to merge 60 commits into
devfrom
slock/session-view-rewrite-11b1
Closed

refactor(session): rewrite session view per W1 visual lock for slice 11b.1#589
Astro-Han wants to merge 60 commits into
devfrom
slock/session-view-rewrite-11b1

Conversation

@Astro-Han
Copy link
Copy Markdown
Owner

@Astro-Han Astro-Han commented May 12, 2026

Summary

Rewrites the session view (conversation flow surface) against the W1 visual lock for issue #440 slice 11b.1, and finishes the work by mounting the new W1 leaves on the default user path so the visual is reachable from a fresh bun run dev:desktop.

  • Phase 1 (TDD reducers): pure groupParts reducer + TrowBlock summary reducer with full unit tests.
  • Phase 2 leaf components: session-turn-user-bubble · session-turn-agent-round · session-turn-event · session-turn-jump · session-turn-trow-block — context-free, caller-injected i18n labels + render slots, focus-within keyboard a11y.
  • Phase 2a session-turn helper extraction (session-turn.tsx): 748 → 446 lines. Extracted TurnChangesList + SessionTurnDiffsList + pure helpers (session-turn-helpers.ts) into siblings.
  • Phase 2b W1 leaf integration (session-turn.tsx + message-timeline.tsx): <Message> and <AssistantParts> are replaced on the default user path by <SessionTurnUserBubble> and <SessionTurnAgentRound>; the <JumpToBottom> leaf replaces the inline ↓ floating button in message-timeline.tsx. Bridge logic (model name resolver, partsByMessage map, partID → owner-message lookup, isLatestRound derivation, i18n label bundles, action wrappers, renderProse / renderTool slots) lives in a new sibling session-turn-leaf-bridges.tsx so the shell stays ≤500 lines. actions.fork is wired through to a new forkRound handler in session.tsx that mirrors the existing DialogFork.handleSelect SDK flow (no dialog — the message id is implicit from the round the toolbar lives on). Slice 11b.1 §6.14 rapid-click guard included.
  • Phase 3a + 3c message-part split: message-part.tsx 2459 → 91 lines. Pulled User/Assistant displays, registry, markdown helpers, tool-info, render-groups, tool dispatcher + display chrome, core part renderers, and four tool-category modules (basic / agent / shell / file) into focused siblings; the aggregator now hosts only the role dispatcher + public barrel re-exports.
  • Phase 3b message-timeline split: message-timeline.tsx 1211 → 467 lines. Extracted seven helpers (staging · scroll · comments · history-load · turn-changes · row · websearch) and dropped orphan title-editor / session-export code left behind by b49b67999 (verified no JSX consumption, no exports, no external consumers via git grep).
  • Phase 4 cross-package: shimmer duration 1200 → 1800ms across all callsites; Fork glyph placeholder registered as fork in the icon registry (lucide GitFork-style stand-in pending the Sheet 15 candidate selection by @Astro-Han); shared AttachmentChip primitive.
  • Phase 5 token registration: --fg-secondary (reasoning text) and --icon-chev (trow chevron color) registered in packages/ui/src/style/theme.css light + dark + media-query block + mirrored in packages/ui/src/theme/themes/pawwork.json per the theme-parity.test.ts literal-value contract.
  • Phase 5 i18n keys: new shared keys for the W1 surface — ui.sessionTurn.workingTime, ui.sessionTurn.trow.summary.{running,completed,withFailed}, ui.sessionTurn.trow.failed, ui.sessionTurn.bubble.fork — added to both zh and en in lock-step.
  • Phase 6 E2E: five new specs under packages/app/e2e/session/ covering the 16 golden-path routes from issue [Feature] Rewrite packages/ui + packages/app visual layer against PawWork design system (incremental PR series) #440 §5.2 — session-w1-bubble.spec.ts (E1, E7, E8, E9, E10), session-w1-trow.spec.ts (E3, E4, E11, E12, E13), session-w1-events.spec.ts (E2, E5, E15, E16), session-w1-jump.spec.ts (E6), session-w1-error.spec.ts (E14). Routes that issue [Feature] Rewrite packages/ui + packages/app visual layer against PawWork design system (incremental PR series) #440 §5.2 explicitly defers to unit / visual (focus-visible state matrix, working-time precise timing, shimmer rhythm, reduce-motion, i18n copy switch) stay in the unit suite.

Hybrid surface revoked: an earlier hybrid SessionTurnV2 opt-in mount was reverted (commit 0fbee2945) in favor of the design-doc-mandated in-place rewrite — docs/design/preview/message-flow.html §1 explicitly forbids co-existence of the old and new session-turn.tsx. The Phase 2b integration commit (cb90f6b5c) is the moment the W1 surface becomes the default user path.

AGENTS.md ≤500 line rule satisfied:

  • All 14 message-part modules between 53 and 396 lines.
  • All 8 message-timeline modules between 37 and 467 lines.
  • All 5 session-turn shell-side modules between 133 and 497 lines (session-turn.tsx 497, session-turn-leaf-bridges.tsx 176, …).

PR is held in in_review until @Astro-Han runs the dev:desktop checklist hands-on. No option-b auto-merge signal will be posted — visual-impact PRs must clear hands-on validation before merge.

Why

DESIGN.md L406-L477 and workshop preview docs/design/preview/message-flow.html (locked 2026-05-12) replace the current session-turn / message-part outer shell with a flat agent round + cream user bubble + interleaved tool-status rows + always-visible working-time header + hover toolbars + system event line + ↓ floating button. The pre-rewrite files were 748 / 2459 / 1211 lines respectively — well over the AGENTS.md 500-line ceiling and impossible to review at the structural level. This rewrite splits each into single-responsibility files so the right-pane work in slice 11b.2 has a frozen public contract to import, and ships the integration commit that makes the W1 visual the live default — design doc §1 forbids co-existence.

Related Issue

Refs #440 (parent), #154 and #65 (sibling concerns, already closed).

Human Review Status

Status: in_review — pending hands-on dev:desktop verification by @Astro-Han.

Per slice 11b.1 safety brief, this PR must not be merged on diff-only review. The 17-item dev:desktop checklist (light/dark, attachments, working time tick, trow grouping transitions, shimmer rhythm, ↓ button, system event, reset path, fork path, palette /fork backup, multi-turn scroll, reduce-motion, long prose, trow error, raw output, focus matrix, i18n switch) is reproduced under How To Verify for @Astro-Han to run before merge.

Review Focus

  • Visual fidelity vs W1 (docs/design/preview/message-flow.html): bubble cream + radius-lg, attachments above bubble, agent flat (no turn-frame), mf-agent-header always-visible working-time tick, hover toolbar 8 / 4 / 12 group spacing without · separators, shimmer 1800ms across all callsites.
  • Phase 2b integration (cb90f6b5c): the shell adapter from the legacy <Message> / <AssistantParts> path to the W1 leaves. The bridge factory in session-turn-leaf-bridges.tsx resolves the model display name, builds the partsByMessage map for the agent-round's groupParts() walk, resolves the partID → owner-message lookup for the trow renderTool slot, derives isLatestRound, wraps actions.revert and actions.fork into the leaves' zero-arg callbacks, and injects <PacedMarkdown> + <Part> as the prose / tool render slots so per-tool rich bodies (file accordion, raw output, copy button on hover) keep working. No behavior shift for assistantCopyPartID, turnDurationMs, showAssistantCopyPartID, or auto-scroll anchoring.
  • Public contract for slice 11b.2: only SessionTurn, JumpToBottom, UserMessageDisplay, AssistantMessageDisplay, MessageTimeline, useTimelineScroll are exported. The new sibling modules (message-part-*, message-timeline-*, session-turn-*-list, session-turn-helpers, session-turn-leaf-bridges, session-turn-{user-bubble,agent-round,event,jump,trow-block}) are private to this package and should stay so.
  • groupParts algorithm (message-part-group.ts): prose and reasoning act as flush boundaries between consecutive tool runs; hidden tools (todowrite), running question tools, empty text, and unknown SDK part types are filtered without flushing — please scrutinise the test matrix and the unknown-type silent-skip path.
  • a11y patches on top of preview lock: :focus-within added next to :hover on bubble/agent toolbars (keyboard-only users); agent toolbar uses visibility: hidden + pointer-events: none + aria-hidden (not opacity-only) while running, to keep Tab order from reaching inert buttons.
  • Rapid-click guards: Reset (built-in to the SessionTurnUserBubble resetting signal) and the new forkRound handler in session.tsx (top-level forking signal) both gate concurrent SDK calls. Reset is idempotent, Fork is not — the guard is mandatory for Fork.
  • Orphan code removal in message-timeline.tsx: titleMutation / onExport / openTitleEditor / closeTitleEditor / saveTitleEditor / navigateParent / errorMessage / exportAvailable / tint / workingStatus / timeoutDone plus their dependent imports were left behind by b49b67999 ("refactor(app): drop duplicate session title bar"). git grep verified zero JSX consumption, zero exports, zero external readers; the cleanup is part of this PR rather than a follow-up to keep message-timeline.tsx ≤500 lines without artificial padding.

Risk Notes

  • Pre-existing dev baseline has 3 unrelated failing tests in packages/ui (not introduced by this PR):

    • Button — single canonical size (28px) > height 28px declared outside any variant selector
    • IconButton — single canonical size (24×24) > width 24px declared at root level
    • IconButton — single canonical size (24×24) > height 24px declared at root level

    Root cause: fixture rot — these tests still assert the pre-rebalance sizes after Button / IconButton CSS was updated to W1 spec (iconbtn 24→30, radius-sm→radius-md). These tests are not in any required CI check (unit-app / unit-opencode / unit-desktop turbo filters do not run packages/ui tests). Tracked as a separate follow-up.

  • Fork glyph is a placeholder: a lucide GitFork-style stand-in is registered in packages/ui/src/components/icon.tsx as fork. The dedicated Sheet 15 candidate will be selected by @Astro-Han; a one-line replacement updates the SVG once the final candidate is chosen — no other code changes required.

  • Local-only docs reconciliation: any follow-up edits to docs/DESIGN.md and docs/design/preview/message-flow.html are excluded via .git/info/exclude and do not appear in the PR diff. The code changes here track the visual lock as of 2026-05-12; doc edits land separately.

  • Long-session performance: virtualization is explicitly out of slice 11b.1 (excluded by [Feature] Rewrite packages/ui + packages/app visual layer against PawWork design system (incremental PR series) #440 / [Task] Optimize long session timeline rendering to prevent streaming flicker #397). <For> keyed reconciliation + createMemo with referential equality keep re-render scope tight, but a 100+ turn session is not load-tested in this PR.

  • E2E coverage scope: the five new spec files focus on structural / behavioural assertions for the W1 surface (DOM presence, click-through wiring, render-slot dispatch). Visual rhythm (shimmer cadence, reduce-motion) and timing-sensitive items (precise working-time tick rate) stay in the unit suite and the dev:desktop checklist per issue [Feature] Rewrite packages/ui + packages/app visual layer against PawWork design system (incremental PR series) #440 §5.2 — those routes are unstable in Playwright fake-timer scenarios.

How To Verify

Local: bun install && bun run dev:desktop, then walk the 17-item checklist below in light + dark.

CI:

  • tsgo --noEmit GREEN across packages/ui and packages/app.
  • bun test packages/ui 478 pass / 3 baseline fail (Button/IconButton fixture rot — pre-existing, see Risk Notes).
  • bun test packages/app 1050 pass / 0 fail.
  • bun test:e2e -- session/session-w1-*.spec.ts covers E1-E16.
  • Required checks: typecheck / analyze-js-ts / unit-app / unit-desktop / unit-opencode / smoke-macos-arm64 / CodeRabbit / pr-title-lint / CodeQL.

Hands-on dev:desktop checklist (@Astro-Han):

  1. Light + dark theme — bubble cream + --fg-secondary + --icon-chev resolve to design-doc values; no fallback flashes on initial paint.
  2. Attachments — chip row sits above the bubble; horizontal scroll on overflow; close button only in dock editor state.
  3. Working-time tick — header 已工作 16s ticks every second while the round runs; freezes at the final value when the round completes (no spinner, no pulsing dot).
  4. Trow grouping transitions — consecutive tool calls render as separate trows at gap 8; prose / reasoning flush the group so the next tool starts a fresh run.
  5. Shimmer rhythm — text shimmer at 1800ms across all callsites (was 1200ms in pre-rewrite).
  6. ↓ floating button — centered above composer dock, appears when user scrolls up, scroll-and-relock on click.
  7. System event line — single left-aligned caption line at --fg-weak at gap 12 to neighbours; no icon, no background, no prefix.
  8. Reset path — bubble hover toolbar [Reset] calls the existing revert flow + reopens the user message for editing.
  9. Fork path — agent hover toolbar [Fork] (placeholder glyph) opens a new session branched from this round, preserving history up to and including the previous round.
  10. Fork glyph backup — placeholder lucide GitFork stand-in renders correctly until Sheet 15 candidate is selected.
  11. Multi-turn scroll — auto-lock to bottom on new content; manual scroll-up releases the lock; manual scroll-to-bottom re-locks.
  12. Reduce-motion — prefers-reduced-motion: reduce disables shimmer + transitions + the trow expand animation.
  13. Long prose — agent prose wraps cleanly, no max-width clamp beyond the central column 720 cap.
  14. Trow error — failed tool calls render with the error indicator + raw error body inside the expanded body.
  15. Raw output — bash tool raw output preserves whitespace + ANSI stripping + copy-to-clipboard action.
  16. Focus matrix — keyboard tab order skips inert toolbar buttons while the agent is running (aria-hidden + pointer-events: none); :focus-within reveals the hover toolbar.
  17. i18n switch — zh ↔ en runtime switch; no untranslated keys; "已工作 16s" / "Worked for 16s" both render correctly.

Test Plan

  • bun run check (turbo: typecheck + lint + format)
  • bun test packages/ui (478 pass / 3 baseline fail — see Risk Notes)
  • bun test packages/app (1050 pass / 0 fail)
  • bun test:e2e -- session/session-w1-*.spec.ts (CI run)
  • bun run dev:desktop hands-on 17-item checklist (@Astro-Han)
  • CodeRabbit + Gemini auto-review pass
  • Required CI checks GREEN before merge gate clears

Screenshots

Hands-on screenshots from @Astro-Han's dev:desktop run will be attached before merge.

Notes for Reviewers

  • All split modules are private to their packages — only the existing public surface (SessionTurn, JumpToBottom, UserMessageDisplay, AssistantMessageDisplay, MessageTimeline, useTimelineScroll) is re-exported from the barrel files. The new sibling modules (message-part-*, message-timeline-*, session-turn-{user-bubble,agent-round,event,jump,trow-block,leaf-bridges,*-list,helpers}) are intentionally internal so a future reorganization doesn't break out-of-package consumers.
  • Atomic commits: 36 commits, each a single reversible intent. Side-effect registrations (tool renderers, part renderers) are explicit import "./module" statements in the aggregators so the load order is grep-able.
  • No co-existence: per design doc §1, the new visual is the default user-path through the same session-turn.tsx symbol. The earlier hybrid SessionTurnV2 mount was reverted in 0fbee2945; Phase 2b commit cb90f6b5c is the moment the W1 leaves replace the legacy renderers in session-turn.tsx's JSX.

Summary by CodeRabbit

  • New Features

    • Rich attachment chips (file/image previews, removable)
    • Agent-round UI (working-time, copy & fork, interrupted/system-event badges)
    • User message bubble (attachments, copy & reset, session fork)
    • Collapsed tool-group summaries (“trow” blocks) with expandable per-tool details
    • New tool renderers (shell, file diffs, agent/task cards) and diagnostics
    • Message comments list, paced streaming reveal, jump-to-bottom, staged timeline loading, web-search recovery toasts, undo/redo turn-change UI
  • Improvements

    • Longer shimmer animations
    • Timeline/scroll hardening (layout-interaction handling, auto-scroll hold, improved restore behavior)

Astro-Han added 2 commits May 13, 2026 01:20
Adds the pure grouping reducer that splits an assistant message's parts
into ordered render units (prose / reasoning / trow-block) for the
session-view rewrite in slice 11b.1.

Algorithm follows DESIGN.md L466 / issue #440 design doc §3.1: prose and
reasoning act as flush boundaries between consecutive tool runs; hidden
/ unknown / empty parts are skipped silently and do not split a logical
operation. Tests cover the eight scenarios listed in §5.1 row #1 plus
ordering preservation across multiple flushes.

Refs #440.
Adds the pure derivation that the upcoming session-turn-trow-block
component will use to compute its summary row: total count, running
flag, failed count, and family icon resolved from the first tool's
type. The reducer mirrors getToolInfo()'s icon switch as a pure
function (no i18n context) so the leading icon can be picked outside
of a Solid root and unit-tested.

i18n key selection (`session.trow.summary.{running|withFailed|completed}`)
also lives here so the component is a thin wrapper. The component itself
is a stub at this commit; the visual implementation lands in slice 11b.1
Phase 2b together with chev / expand / shimmer wiring.

Refs #440 design doc §3.1 / §3.6 / DESIGN.md L471.
@Astro-Han Astro-Han added P2 Medium priority ui Design system and user interface tech-debt Internal cleanup and maintainability debt labels May 12, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 12, 2026

Warning

Rate limit exceeded

@Astro-Han has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 36 minutes and 45 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fc90a82b-e5e4-43cc-b1f8-4479414897e6

📥 Commits

Reviewing files that changed from the base of the PR and between 969f6a9 and f49fd6d.

📒 Files selected for processing (7)
  • packages/app/src/context/global-sdk-event-queue.test.ts
  • packages/app/src/context/global-sdk-event-queue.ts
  • packages/app/src/context/global-sdk.test.ts
  • packages/app/src/context/global-sdk.tsx
  • packages/app/src/pages/session/terminal-panel.tsx
  • packages/ui/src/components/session-turn-trow-block.css
  • packages/ui/src/components/text-shimmer.css
📝 Walkthrough

Walkthrough

Adds AttachmentChip UI/CSS, message-part types/registry/markdown/tool-info, deterministic grouping and trow-block UI, many tool renderers, session-turn leaf components and bridges, timeline staging/scroll/turn-change/websearch helpers, theme/token/animation tweaks, and tests/E2E.

Changes

Session turn v2 leaf components and utilities

Layer / File(s) Summary
AttachmentChip UI primitive
packages/ui/src/components/attachment-chip.tsx, packages/ui/src/components/attachment-chip.css, packages/ui/src/components/attachment-chip.test.ts
AttachmentChipProps drives file/image variants with derived ext and optional removable control; CSS defines file/image layouts and remove-button focus ring; tests assert structure and geometry.
Part types & registry
packages/ui/src/components/message-part-types.ts, packages/ui/src/components/message-part-registry.tsx
Type-only message/part/tool contracts and mutable PART_MAPPING/tool registry with Part dispatcher and ToolRegistry.
Paced markdown & path helpers
packages/ui/src/components/message-part-markdown.tsx
createPacedValue() pacing/snapping, MessageMarkdown/PacedMarkdown wrappers, absolute-like path detection, join/relativize helpers.
Tool info & session-turn helpers
packages/ui/src/components/message-part-tool-info.ts, packages/ui/src/components/session-turn-helpers.ts
Agent tone palette, tone() resolver, taskAgent(), getToolInfo() factory, and safe toolStateMetadata/toolStateError, plus unwrap, heading, partState utilities.
Message-part grouping
packages/ui/src/components/message-part-render-groups.ts, packages/ui/src/components/message-part-group.ts, packages/ui/src/components/message-part-group.test.ts
Legacy render-groups split and new deterministic groupParts() producing prose/reasoning/trow-block groups; tests cover grouping, filtering, and ordering.
Core part renderers
packages/ui/src/components/message-part-core-renderers.tsx
Registers compaction divider, text and reasoning renderers with paced markdown, copy affordances, and streaming metadata.
Tool renderers (basic/file/agent/shell)
packages/ui/src/components/message-part-tools-*.tsx
Registers many tool renderers (read/list/glob/grep, webfetch/websearch, todowrite/question/skill, edit/write/apply_patch, task/agent, bash) using ToolRegistry, including diagnostics, accordions, diffs, and copy UX.
Tool part display
packages/ui/src/components/message-part-tool-display.tsx
ToolPart dispatcher (PART_MAPPING["tool"]), ShellSubmessage, DiagnosticsDisplay, ToolFileAccordion, and error/special-case handling.
Trow-block reducer & component
packages/ui/src/components/session-turn-trow-block.tsx, packages/ui/src/components/session-turn-trow-block.css, packages/ui/src/components/session-turn-trow-block.reducer.test.ts
reduceTrowBlock, toolFamilyIcon, trowSummaryI18nKey, TrowBlock details/summary UI and CSS; tests validate reducer/key logic.
SessionTurn leaf components
packages/ui/src/components/session-turn-user-bubble.*, packages/ui/src/components/session-turn-agent-round.*, packages/ui/src/components/session-turn-event.*, packages/ui/src/components/session-turn-jump.*
User bubble (attachments, joined text, copy/reset), Agent round (grouped parts, working-time ticker, copy/fork), SystemEvent (muted caption), JumpToBottom (floating button); CSS and source-grep tests included.
Leaf bridge & session-turn refactor
packages/ui/src/components/session-turn-leaf-bridges.tsx, packages/ui/src/components/session-turn.tsx
Bridge factory adapting shell state to leaf render slots (renderProse, renderTool); SessionTurn refactored to use leaf components and new turn-changes/diffs lists, prop changes (adds onTrowLayoutInteraction).
Timeline & app helpers / staging / turn-changes / websearch
packages/app/src/pages/session/*
Refactors MessageTimeline to use staging, turn-change fetcher, websearch watcher, scroll helpers, TimelineMessage, LoadEarlierButton, message comments, turn-changes/diffs lists; adds E2E Playwright specs for bubble/agent/trow/events/jump/error flows.
Scroll controllers & dock interaction
packages/app/src/pages/session/use-session-scroll-dock.ts, packages/app/src/pages/session/session-timeline-scroll-controller.ts, packages/app/src/pages/session/use-session-timeline-interaction.ts, packages/app/src/pages/session/session-timeline-scroll-anchors.ts
New layout_interaction intent, reading anchors with anchorID, anchor-aware sampling/restoration, removal/replacement of bottom-follow lock machinery, and API changes (scrollToLatest/shouldStickToBottom).
Theme, animations, icons
packages/ui/src/styles/theme.css, packages/ui/src/theme/themes/pawwork.json, packages/ui/src/components/text-shimmer.css, packages/ui/src/styles/animations.css, packages/ui/src/components/icon.tsx
Adds --fg-secondary and --icon-chev tokens, PawWork JSON overrides, increases shimmer durations to 1800ms, and adds "fork" glyph.
Tests / E2E
packages/ui/**/*.test.ts, packages/app/e2e/session/*.spec.ts
Many new Bun source-grep/unit tests and multiple Playwright E2E specs covering W1 behaviors: bubble, agent, trow grouping, events, jump, error, and scroll-layout regressions.

"I stitched a tiny attachment chip,
grouped trows and made the shimmers slip.
Icons forked, timelines learned to wait,
tests and jumps now guard the gate.
Hooray — a rabbit shipped the update! 🐰"

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch slock/session-view-rewrite-11b1

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested priority: P2 (includes non-doc, non-test paths outside the low-risk bucket).

P1/P0 are reserved for maintainer confirmation. Please relabel manually if this is a release blocker, security issue, data-loss risk, or updater/runtime failure.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements logic to group assistant message parts into renderable units, specifically aggregating consecutive tool calls into summary blocks while maintaining prose and reasoning as distinct segments. It includes the groupParts utility, a reducer for tool block metadata, and corresponding unit tests. Feedback was provided to simplify the groupParts function by removing unnecessary type casts, allowing TypeScript's control flow analysis to handle type narrowing.

Comment on lines +79 to +94
if (part.type === "tool") {
pendingTools.push(part as ToolPart)
continue
}
if (part.type === "text") {
flushTools()
const t = part as TextPart
groups.push({ kind: "prose", partID: t.id, text: t.text })
continue
}
if (part.type === "reasoning") {
flushTools()
const r = part as ReasoningPart
groups.push({ kind: "reasoning", partID: r.id, text: r.text })
continue
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The explicit type casts to ToolPart, TextPart, and ReasoningPart are unnecessary. TypeScript's control flow analysis automatically narrows the type of part within the if (part.type === ...) blocks. Removing these casts makes the code cleaner and allows the type system to catch potential errors, improving maintainability.

    if (part.type === "tool") {
      pendingTools.push(part)
      continue
    }
    if (part.type === "text") {
      flushTools()
      groups.push({ kind: "prose", partID: part.id, text: part.text })
      continue
    }
    if (part.type === "reasoning") {
      flushTools()
      groups.push({ kind: "reasoning", partID: part.id, text: part.text })
      continue
    }

Astro-Han added 2 commits May 13, 2026 01:25
W1 visual preview (docs/design/preview/message-flow.html, locked
2026-05-12) widened the shimmer duration to 1800ms to match the
Bill Chung "slow and steady" 1.5-2s industry midrange and to keep
long phrases continuously visibly shimmering instead of finishing
in one screen-width.

Two-line change: `--text-shimmer-duration` in text-shimmer.css and
`--animate-pw-shimmer` keyframe duration in animations.css. The
component API, gradient size, and direction stay unchanged — only
the wall-clock speed shifts. Confirmed no remaining 1200ms shimmer
references via `grep -rE "1200ms" packages/ui/src`.

Refs #440 design doc §4.1 / DESIGN.md Motion.
Adds a `fork` entry to the chrome icon registry so the slice 11b.1
agent-toolbar Fork button can resolve `<Icon name="fork" />` without
falling back to a missing-icon state. Placeholder path data is the
existing `branch` glyph — the W1 preview lock-stamp explicitly
documents this as the agreed temporary stand-in until imagegen
Sheet 15's dedicated Fork candidate is picked by Astro-Han. The
inline comment names the swap-out site so the eventual one-line
SVG replacement is unambiguous.

Refs #440 design doc §4.2.
@Astro-Han Astro-Han marked this pull request as ready for review May 12, 2026 17:27
Astro-Han added 4 commits May 13, 2026 01:27
Adds the shared attachment chip primitive that the user-message bubble
(read-only mode) and the composer dock (editable mode) will both render,
fulfilling DESIGN.md L444 "one attachment spec across the product".

Geometry mirrors the W1 visual lock in
docs/design/preview/message-flow.html:

- File chip: 64-tall row with a 48×48 surface-sunken square holding a
  32-px file-type icon (left), and a 2-line right slot (filename body
  with ellipsis + uppercase extension caption with letter-spacing 0.5).
- Image chip: 64×64 square with `object-fit: cover`, hairline border,
  shared radius-md with the file chip so they read as one family.
- Close button: 24-circle, inverse fill, floating at -6/-6 top-right,
  gated by `removable={true}`. Bubble passes `removable={false}` so
  archived attachments never show the × (W1 L1088).

The composer dock migration to this primitive is intentionally deferred
to a sibling slice — slice 11b.1 is bubble-side only — but the API
ships ready for both consumers.

Tests follow the source-grep convention used by message-part-stale:
they pin the structural invariants (data-kind branches, Show-gated
remove button, W1 CSS geometry) without introducing a new
testing-library dependency. Behavioural coverage lands in
session-turn-user-bubble.test.tsx (Phase 2c).

Refs #440 design doc §3.7 / DESIGN.md L444.
Adds the presentational user bubble for the slice 11b.1 conversation
flow rewrite. The bubble consumes the shared AttachmentChip primitive
in `removable={false}` mode for the attachment row, joins multiple
non-synthetic text parts with `\n\n`, renders plain text via JSX
interpolation (XSS-safe), and exposes the hover-only bottom toolbar
with a meta segment (model + 24h `HH:mm` time, no `·` separator) and
an actions segment ([Copy] [Reset]).

Visibility is CSS-only: the toolbar is always mounted, with
`opacity: 0; pointer-events: none` defaults that flip on `:hover`,
`:focus-within`, or a `data-hover` attribute. The `:focus-within`
trigger is the a11y patch on top of the W1 preview lock so
keyboard-only users can surface the toolbar by tabbing.

The bubble is context-free: it accepts pre-resolved `modelName`,
i18n `labels`, and an `actions` object for clipboard / reset entry
points. The slice 11b.1 SessionTurn shell wires real SDK paths
(`sdk.client.session.revert` + `navigator.clipboard.writeText`) into
these props in the upcoming Phase 2a integration commit.

Refs #440 design doc §3.3 / §3.6 / §6.13-6.18 / DESIGN.md L453-L462.
Adds the muted-caption system event line for slice 11b.1. Only the
`interrupted` kind is wired tonight (assistantMessage.error?.name
=== "MessageAbortedError"), but the SystemEventKind enum predeclares
the full W1 taxonomy (`connection-lost` / `connection-restored`) so
the sync-layer follow-up in 11b.2 can light them up without changing
this component's public API.

The component is context-free: it accepts a pre-resolved `label`
string from the caller, so the i18n routing decision lives in the
SessionTurn agent round, not here. Visually: one `<div>` with
`--type-caption` / `--fg-weak`, left-aligned, no chrome —
DESIGN.md L475's muted-line spec.

Refs #440 design doc §3.5 / DESIGN.md L475.
Adds the floating jump-to-bottom button that appears above the
composer dock whenever the user has scrolled up from the bottom of
the timeline.

Visibility is a single signal: `!pinned`. The "has new content since
unlock" gate the W1 first draft toyed with is intentionally absent —
one fewer state signal, one fewer test branch, one fewer mental model
to maintain (slice 11b.1 §3.4 three-question simplification).

The button is presentational only — it accepts `visible` / `onClick`
/ `label` props and nothing else. The scroll-state plumbing
(`useTimelineScroll`) lives in packages/app/src/pages/session/ and
computes `jumpButtonVisible` + `onJumpClick` for the SessionTurn
shell to thread through. Geometry follows DESIGN.md L477's icon-
button round floating variant (30×30, surface-raised, hairline,
shadow-floating, hover 4% overlay, chevron-down 16-px glyph).

Refs #440 design doc §3.4 / DESIGN.md L477.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
packages/ui/src/components/icon.tsx (1)

18-23: ⚡ Quick win

Avoid duplicating the full SVG payload for placeholder fork

This works, but duplicating the huge path string makes future updates error-prone. Reuse a shared glyph constant for both branch and fork until the final fork asset lands.

♻️ Suggested refactor
+const branchGlyph = `<g transform="translate(1.000 1.233) scale(0.093264)"><path d="..." fill="currentColor"/></g>`

 export const icons = {
-  "branch": `<g transform="translate(1.000 1.233) scale(0.093264)"><path d="..." fill="currentColor"/></g>`,
+  "branch": branchGlyph,
   // Slice 11b.1 placeholder...
-  "fork": `<g transform="translate(1.000 1.233) scale(0.093264)"><path d="..." fill="currentColor"/></g>`,
+  "fork": branchGlyph,
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/icon.tsx` around lines 18 - 23, Replace the
duplicated SVG string for the "fork" icon by referencing the same shared glyph
used for "branch": create a single constant (e.g., BRANCH_GLYPH or
SHARED_BRANCH_SVG) holding the large <g>…</g> payload and assign that constant
to both the "branch" and "fork" keys instead of inlining the full path for
"fork"; update the icons map so "fork" points to the shared constant to avoid
future divergence.
packages/ui/src/components/attachment-chip.test.ts (1)

43-43: ⚡ Quick win

Loosen the image CSS regex to avoid order-coupled test failures.

This assertion currently depends on two selectors being adjacent in a specific order; harmless CSS reordering can break the test.

Suggested patch
-  expect(css).toMatch(/\[data-kind="image"\][^}]*\}\s*\n\s*\[data-component="attachment-chip"\]\[data-kind="image"\][^{}]*\[data-slot="attachment-chip-image"\][^{}]*\{[^}]*object-fit:\s*cover/)
+  expect(css).toMatch(/\[data-component="attachment-chip"\]\[data-kind="image"\][^{}]*\[data-slot="attachment-chip-image"\][^{}]*\{[^}]*object-fit:\s*cover/)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/attachment-chip.test.ts` at line 43, The test's
regex in attachment-chip.test.ts is order-coupled and requires the
[data-kind="image"] selector and the
[data-component="attachment-chip"][data-kind="image"][data-slot="attachment-chip-image"]
block to be adjacent; loosen it by changing the assertion on the css string so
it no longer requires specific adjacency—either assert the presence of each
selector/block separately (e.g., two toMatch calls checking [data-kind="image"]
and the block containing object-fit: cover) or modify the regex to allow
arbitrary intervening characters between the selectors (use a
non-order-dependent pattern) so harmless CSS reordering won't break the test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/ui/src/components/attachment-chip.css`:
- Around line 100-115: The remove button styled by the selector
[data-component="attachment-chip"] [data-slot="attachment-chip-remove"] is
interactive but uses cursor: default; change its cursor to pointer so it appears
clickable. Locate the rule for the remove control (selector with
data-component="attachment-chip" and data-slot="attachment-chip-remove") and
replace cursor: default with cursor: pointer (or add cursor: pointer) while
leaving the other layout/visual properties intact.

In `@packages/ui/src/components/attachment-chip.tsx`:
- Around line 66-67: The image branch renders even when previewUrl is absent;
update the conditional guards on the Show components that check props.kind ===
"image" to also require props.previewUrl (e.g., change to props.kind === "image"
&& props.previewUrl) so the image slot only renders when a previewUrl exists;
apply the same guard to the second Show block that handles the image preview
(the block around the logic currently at lines ~107-111) and ensure any <img>
usage uses props.previewUrl safely.
- Around line 88-97: When rendering the remove button in the attachment chip,
ensure it always has an accessible name by providing a default label when
props.removeLabel is missing: change the aria-label usage on the button in the
removable branch to use props.removeLabel ?? 'Remove attachment' (and do the
same for the second occurrence around the Icon branch at the other location).
Also mark the Icon component as decorative (e.g., ensure Icon is aria-hidden or
otherwise not exposing an extra name) so the button's aria-label is the actual
accessible name, and keep onRemove as the click handler.

---

Nitpick comments:
In `@packages/ui/src/components/attachment-chip.test.ts`:
- Line 43: The test's regex in attachment-chip.test.ts is order-coupled and
requires the [data-kind="image"] selector and the
[data-component="attachment-chip"][data-kind="image"][data-slot="attachment-chip-image"]
block to be adjacent; loosen it by changing the assertion on the css string so
it no longer requires specific adjacency—either assert the presence of each
selector/block separately (e.g., two toMatch calls checking [data-kind="image"]
and the block containing object-fit: cover) or modify the regex to allow
arbitrary intervening characters between the selectors (use a
non-order-dependent pattern) so harmless CSS reordering won't break the test.

In `@packages/ui/src/components/icon.tsx`:
- Around line 18-23: Replace the duplicated SVG string for the "fork" icon by
referencing the same shared glyph used for "branch": create a single constant
(e.g., BRANCH_GLYPH or SHARED_BRANCH_SVG) holding the large <g>…</g> payload and
assign that constant to both the "branch" and "fork" keys instead of inlining
the full path for "fork"; update the icons map so "fork" points to the shared
constant to avoid future divergence.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a19f39b9-06ad-476c-9cfa-4f59e65df6f7

📥 Commits

Reviewing files that changed from the base of the PR and between ebe68d9 and 722d9fa.

📒 Files selected for processing (10)
  • packages/ui/src/components/attachment-chip.css
  • packages/ui/src/components/attachment-chip.test.ts
  • packages/ui/src/components/attachment-chip.tsx
  • packages/ui/src/components/icon.tsx
  • packages/ui/src/components/message-part-group.test.ts
  • packages/ui/src/components/message-part-group.ts
  • packages/ui/src/components/session-turn-trow-block.reducer.test.ts
  • packages/ui/src/components/session-turn-trow-block.tsx
  • packages/ui/src/components/text-shimmer.css
  • packages/ui/src/styles/animations.css

Comment on lines +100 to +115
[data-component="attachment-chip"] [data-slot="attachment-chip-remove"] {
position: absolute;
top: -6px;
right: -6px;
width: 24px;
height: 24px;
border-radius: 9999px;
background: var(--fg-strong);
color: var(--bg-base);
border: 0;
padding: 0;
cursor: default;
display: inline-flex;
align-items: center;
justify-content: center;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use a pointer cursor for the removable close button.

The remove control is clickable, but cursor: default makes it appear non-interactive.

Suggested patch
 [data-component="attachment-chip"] [data-slot="attachment-chip-remove"] {
   position: absolute;
   top: -6px;
   right: -6px;
@@
-  cursor: default;
+  cursor: pointer;
   display: inline-flex;
   align-items: center;
   justify-content: center;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
[data-component="attachment-chip"] [data-slot="attachment-chip-remove"] {
position: absolute;
top: -6px;
right: -6px;
width: 24px;
height: 24px;
border-radius: 9999px;
background: var(--fg-strong);
color: var(--bg-base);
border: 0;
padding: 0;
cursor: default;
display: inline-flex;
align-items: center;
justify-content: center;
}
[data-component="attachment-chip"] [data-slot="attachment-chip-remove"] {
position: absolute;
top: -6px;
right: -6px;
width: 24px;
height: 24px;
border-radius: 9999px;
background: var(--fg-strong);
color: var(--bg-base);
border: 0;
padding: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/attachment-chip.css` around lines 100 - 115, The
remove button styled by the selector [data-component="attachment-chip"]
[data-slot="attachment-chip-remove"] is interactive but uses cursor: default;
change its cursor to pointer so it appears clickable. Locate the rule for the
remove control (selector with data-component="attachment-chip" and
data-slot="attachment-chip-remove") and replace cursor: default with cursor:
pointer (or add cursor: pointer) while leaving the other layout/visual
properties intact.

Comment on lines +66 to +67
<Show
when={props.kind === "image"}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard the image branch when previewUrl is missing.

If kind="image" arrives without previewUrl, the component renders a broken/empty image slot.

Suggested patch
     <Show
-      when={props.kind === "image"}
+      when={props.kind === "image" && !!props.previewUrl}
       fallback={

Also applies to: 107-111

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/attachment-chip.tsx` around lines 66 - 67, The
image branch renders even when previewUrl is absent; update the conditional
guards on the Show components that check props.kind === "image" to also require
props.previewUrl (e.g., change to props.kind === "image" && props.previewUrl) so
the image slot only renders when a previewUrl exists; apply the same guard to
the second Show block that handles the image preview (the block around the logic
currently at lines ~107-111) and ensure any <img> usage uses props.previewUrl
safely.

Comment on lines +88 to +97
<Show when={props.removable}>
<button
type="button"
data-slot="attachment-chip-remove"
aria-label={props.removeLabel}
onClick={(event) => props.onRemove?.(event)}
>
<Icon name="close" />
</button>
</Show>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Ensure the remove button always has an accessible name.

When removable is true and removeLabel is omitted, the button can be rendered without a usable accessible label.

Suggested patch
 export function AttachmentChip(rawProps: AttachmentChipProps & Omit<ComponentProps<"div">, keyof AttachmentChipProps>) {
@@
   const resolvedExt = () => props.ext ?? defaultExtFromName(props.name)
+  const resolvedRemoveLabel = () => props.removeLabel ?? "Remove attachment"
@@
             <button
               type="button"
               data-slot="attachment-chip-remove"
-              aria-label={props.removeLabel}
+              aria-label={resolvedRemoveLabel()}
               onClick={(event) => props.onRemove?.(event)}
             >
@@
           <button
             type="button"
             data-slot="attachment-chip-remove"
-            aria-label={props.removeLabel}
+            aria-label={resolvedRemoveLabel()}
             onClick={(event) => props.onRemove?.(event)}
           >

Also applies to: 112-121

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/attachment-chip.tsx` around lines 88 - 97, When
rendering the remove button in the attachment chip, ensure it always has an
accessible name by providing a default label when props.removeLabel is missing:
change the aria-label usage on the button in the removable branch to use
props.removeLabel ?? 'Remove attachment' (and do the same for the second
occurrence around the Icon branch at the other location). Also mark the Icon
component as decorative (e.g., ensure Icon is aria-hidden or otherwise not
exposing an extra name) so the button's aria-label is the actual accessible
name, and keep onRemove as the click handler.

Astro-Han added 5 commits May 13, 2026 01:36
Adds the agent round — the second-largest single file in slice 11b.1
(~340 lines including pure helpers and the running-tick effect). The
component renders one round of agent activity on the new flat surface
(no turn-frame wrapper, DESIGN.md L463-L469):

- Working-time header: always-visible single caption that ticks every
  second while the round is the latest live one, freezes at the final
  elapsed value once `time.completed` lands. Same DOM node across
  states; only the wrap's `data-running` attribute flips. The
  `document.hidden` gate skips ticking when the tab is hidden, and a
  `visibilitychange` listener refreshes `now` immediately on focus
  return so the counter doesn't show a stale value.
- Grouped body: `groupParts()` (Phase 1a) produces prose / reasoning
  / trow-block units; the caller injects a `renderProse` slot so the
  shell can plug in the 11a MessageMarkdown renderer without dragging
  it into this component. Reasoning defaults to italic + `--fg-weak`
  (DESIGN.md L482 spec calls for `--fg-secondary` — token not yet in
  theme.css; a one-line swap once it ships).
- System event: when the latest assistant in the round carries a
  `MessageAbortedError`, a single `SystemEvent kind="interrupted"`
  line renders after the body. The full W1 taxonomy is reserved by
  `SystemEventKind` for sync-layer follow-ups in 11b.2.
- Agent toolbar: hover-only meta (completion time, 24h `HH:mm`) +
  actions ([Copy] [Fork]). While running, the toolbar is fully
  suppressed (visibility hidden + pointer-events none + aria-hidden)
  so Tab cannot reach inert buttons mid-stream. Fork has the §6.14
  in-flight `disabled` guard inside a `try/finally` so an unmount
  during navigation does not leak the busy flag.

Pure helpers `selectFirstAssistant` / `selectLatestAssistant` /
`isInterrupted` / `computeElapsedSec` are exported for unit testing
without Solid runtime. 22 tests pin the contract (4 selectors / 4
elapsed math cases / 4 interrupt cases + 6 source-grep posture
checks for race / cleanup / suppression).

Refs #440 design doc §3.1 / §3.2 / §3.5 / §3.6 / §6.12 / §6.14 / §6.17 / DESIGN.md L463-L482.
Fills the Phase 1b TrowBlock stub with the actual render shell: a
native <details> element (W1 lock) whose <summary> shows the leading
family icon (16px), the i18n summary text, and a 12-px chev that
rotates 90° on open. The summary text switches between three i18n
keys based on the pure reduceTrowBlock derivation (running /
completed / completedWithFailed), matching the §E14 spec.

The chev / disclosure is suppressed for tool groups whose tools
have no body worth expanding (matches W1 preview's "无中间输出的工具
summary 不渲染 chev" rule).

Per-tool rich rendering — file accordions, raw output, copy button,
diff body — is intentionally delegated to a caller-provided
`renderTool` slot. The SessionTurn shell wires this to the existing
GenericTool / BasicTool / Part renderers from message-part.tsx in
the Phase 2a integration commit, so slice 11b.1 does not
reimplement 11a's tool body. A minimal "name + status" fallback row
ships for callers without a slot (storybook / dev preview).

Pipes a `labels.trow` sub-object through SessionTurnAgentRound so
the round can pass i18n-resolved labels into each TrowBlock without
the trow component reading any context — same pattern as the rest
of slice 11b.1's components.

Verified: 11 reducer tests (unchanged) + ui suite stable at 472
pass / 3 pre-existing baseline fail. Typecheck packages/ui GREEN.

Refs #440 design doc §3.1 / §3.6 / §E14 / DESIGN.md L412-L417 / L471.
`--fg-secondary` (reasoning) and `--icon-chev` (trow chev) are specced
in DESIGN.md L482 / W1 lock but are not yet registered in theme.css.
This slice uses `--fg-weak` / `--icon-base` as the closest substitutes
and adds `TODO(Phase 5 sibling slice)` comments next to each fallback
so the next slice can grep and flip in one line each. Risk Notes in
PR #589 carry the same deferral.
Wires the five new leaf components (user-bubble + agent-round +
trow-block + system-event) together against the existing useData /
useI18n data layer. The shell pre-resolves i18n labels and parts-by-
message maps so the leaves stay context-free and unit-testable.

This component is opt-in by import — it is exported from
@opencode-ai/ui/session-turn-v2 via the package's `./*` glob but is
NOT mounted as the default user-path. The existing SessionTurn keeps
ownership of turnChanges undo/redo, diffs accordion, SessionRetry,
the error card, and the compaction MessageDivider; those land in a
sibling slice after Phase 3a extracts the per-role displays cleanly.

i18n adds `ui.sessionTurnV2.workingTime` + `ui.sessionTurnV2.trow.*`
keys to en.ts / zh.ts. Source-grep tests (9 pass) lock the hybrid
contract: opt-in marker, leaf wiring, renderProse slot, context-free
i18n, no deferred-feature re-implementation, action adapter shape,
isLatestRound derivation, and the flat `--bg-base` 24px CSS rhythm.

Slice 11b.1 PR #589 Status section documents the hybrid mount.
The previous render shell used `<Show fallback={defaultRenderTools(props.parts)}>`,
which built the fallback `<For>` from the parts array captured at
component-creation time. New tools that landed mid-stream while
`renderTool` was undefined (the v2 shell's current path) would not
appear in the body — only the initial parts rendered.

Flip the JSX so `<For each={props.parts}>` is the outer reactive
primitive and the per-item branch picks between `renderTool` and the
inline fallback renderer. New tool parts now flow into the body the
moment they land.

Self-review caught this on the manual pass; no test regression
because the existing reducer tests don't drive the JSX render path.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/ui/src/components/session-turn-v2.tsx (1)

57-57: ⚡ Quick win

showReasoningSummaries is currently a no-op.

This prop is exposed on SessionTurnV2Props but never influences rendering, so callers cannot actually disable reasoning summaries. Please either wire it through to SessionTurnAgentRound behavior or remove it from this API for now.

Also applies to: 236-245

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/session-turn-v2.tsx` at line 57, The prop
showReasoningSummaries on SessionTurnV2Props is declared but unused; either
remove it from the public API or pass it into the child component that renders
summaries. Fix by threading showReasoningSummaries from the SessionTurnV2 props
into the SessionTurnAgentRound invocation (and into any deeper components that
actually render reasoning summaries) and then use that boolean to conditionally
skip rendering the reasoning summary UI (wherever the summary JSX/logic is
implemented). Alternatively, if you choose removal, delete
showReasoningSummaries from SessionTurnV2Props and any references in
SessionTurnV2 and update tests/consumers accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/ui/src/components/session-turn-trow-block.tsx`:
- Around line 98-101: The trowSummaryI18nKey helper returns outdated keys under
"session.trow.summary.*"; update the return values in function
trowSummaryI18nKey (which takes a TrowBlockSummary) to match the new dictionary
namespace "ui.sessionTurnV2.trow.*" (e.g., map running, withFailed, completed to
"ui.sessionTurnV2.trow.running", "ui.sessionTurnV2.trow.withFailed", and
"ui.sessionTurnV2.trow.completed" respectively) so lookups resolve to existing
i18n entries.

In `@packages/ui/src/components/session-turn-user-bubble.tsx`:
- Around line 79-85: The guard that checks the timestamp (const created =
props.message.time?.created) uses typeof created === "number" which permits NaN
and infinities; replace that check with Number.isFinite(created) before passing
created into Intl.DateTimeFormat so only finite numeric timestamps are
formatted; apply the same change to the second occurrence that formats the time
(the other Intl.DateTimeFormat call) to harden both guards.

---

Nitpick comments:
In `@packages/ui/src/components/session-turn-v2.tsx`:
- Line 57: The prop showReasoningSummaries on SessionTurnV2Props is declared but
unused; either remove it from the public API or pass it into the child component
that renders summaries. Fix by threading showReasoningSummaries from the
SessionTurnV2 props into the SessionTurnAgentRound invocation (and into any
deeper components that actually render reasoning summaries) and then use that
boolean to conditionally skip rendering the reasoning summary UI (wherever the
summary JSX/logic is implemented). Alternatively, if you choose removal, delete
showReasoningSummaries from SessionTurnV2Props and any references in
SessionTurnV2 and update tests/consumers accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ce99588d-f6e2-438b-a164-5a296887d466

📥 Commits

Reviewing files that changed from the base of the PR and between 722d9fa and 548fa26.

📒 Files selected for processing (19)
  • packages/ui/src/components/session-turn-agent-round.css
  • packages/ui/src/components/session-turn-agent-round.test.ts
  • packages/ui/src/components/session-turn-agent-round.tsx
  • packages/ui/src/components/session-turn-event.css
  • packages/ui/src/components/session-turn-event.test.ts
  • packages/ui/src/components/session-turn-event.tsx
  • packages/ui/src/components/session-turn-jump.css
  • packages/ui/src/components/session-turn-jump.test.ts
  • packages/ui/src/components/session-turn-jump.tsx
  • packages/ui/src/components/session-turn-trow-block.css
  • packages/ui/src/components/session-turn-trow-block.tsx
  • packages/ui/src/components/session-turn-user-bubble.css
  • packages/ui/src/components/session-turn-user-bubble.test.ts
  • packages/ui/src/components/session-turn-user-bubble.tsx
  • packages/ui/src/components/session-turn-v2.css
  • packages/ui/src/components/session-turn-v2.test.ts
  • packages/ui/src/components/session-turn-v2.tsx
  • packages/ui/src/i18n/en.ts
  • packages/ui/src/i18n/zh.ts
✅ Files skipped from review due to trivial changes (7)
  • packages/ui/src/components/session-turn-event.css
  • packages/ui/src/components/session-turn-user-bubble.test.ts
  • packages/ui/src/components/session-turn-event.tsx
  • packages/ui/src/components/session-turn-jump.test.ts
  • packages/ui/src/components/session-turn-jump.css
  • packages/ui/src/components/session-turn-event.test.ts
  • packages/ui/src/components/session-turn-trow-block.css

Comment on lines +98 to +101
export function trowSummaryI18nKey(summary: TrowBlockSummary): string {
if (summary.running) return "session.trow.summary.running"
if (summary.failedCount > 0) return "session.trow.summary.withFailed"
return "session.trow.summary.completed"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix trowSummaryI18nKey to return existing i18n keys.

This helper returns session.trow.summary.*, but the dictionaries added in this PR use ui.sessionTurnV2.trow.*. That mismatch will resolve to missing text when the helper is consumed.

Suggested patch
 export function trowSummaryI18nKey(summary: TrowBlockSummary): string {
-  if (summary.running) return "session.trow.summary.running"
-  if (summary.failedCount > 0) return "session.trow.summary.withFailed"
-  return "session.trow.summary.completed"
+  if (summary.running) return "ui.sessionTurnV2.trow.running"
+  if (summary.failedCount > 0) return "ui.sessionTurnV2.trow.withFailed"
+  return "ui.sessionTurnV2.trow.completed"
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function trowSummaryI18nKey(summary: TrowBlockSummary): string {
if (summary.running) return "session.trow.summary.running"
if (summary.failedCount > 0) return "session.trow.summary.withFailed"
return "session.trow.summary.completed"
export function trowSummaryI18nKey(summary: TrowBlockSummary): string {
if (summary.running) return "ui.sessionTurnV2.trow.running"
if (summary.failedCount > 0) return "ui.sessionTurnV2.trow.withFailed"
return "ui.sessionTurnV2.trow.completed"
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/session-turn-trow-block.tsx` around lines 98 -
101, The trowSummaryI18nKey helper returns outdated keys under
"session.trow.summary.*"; update the return values in function
trowSummaryI18nKey (which takes a TrowBlockSummary) to match the new dictionary
namespace "ui.sessionTurnV2.trow.*" (e.g., map running, withFailed, completed to
"ui.sessionTurnV2.trow.running", "ui.sessionTurnV2.trow.withFailed", and
"ui.sessionTurnV2.trow.completed" respectively) so lookups resolve to existing
i18n entries.

Comment on lines +79 to +85
const created = props.message.time?.created
if (typeof created !== "number") return undefined
return new Intl.DateTimeFormat(props.locale, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(created)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Harden timestamp guards against NaN / non-finite values.

On Line 80 and Line 90, typeof created === "number" allows NaN and infinities. That can produce invalid time formatting or throw depending on runtime path. Use Number.isFinite(created) before formatting.

Suggested fix
   const timeStamp = createMemo(() => {
     const created = props.message.time?.created
-    if (typeof created !== "number") return undefined
+    if (typeof created !== "number" || !Number.isFinite(created)) return undefined
     return new Intl.DateTimeFormat(props.locale, {
       hour: "2-digit",
       minute: "2-digit",
       hour12: false,
     }).format(created)
   })

   const timeTitle = createMemo(() => {
     const created = props.message.time?.created
-    if (typeof created !== "number") return undefined
+    if (typeof created !== "number" || !Number.isFinite(created)) return undefined
     return new Date(created).toString()
   })

Also applies to: 89-91

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/session-turn-user-bubble.tsx` around lines 79 -
85, The guard that checks the timestamp (const created =
props.message.time?.created) uses typeof created === "number" which permits NaN
and infinities; replace that check with Number.isFinite(created) before passing
created into Intl.DateTimeFormat so only finite numeric timestamps are
formatted; apply the same change to the second occurrence that formats the time
(the other Intl.DateTimeFormat call) to harden both guards.

Astro-Han added 8 commits May 13, 2026 02:13
The slice 11b.1 design doc (#440 comment-4432066973 §1) explicitly
forbids co-existence of old and new `session-turn.tsx`:

> The existing `session-turn.tsx` (748 lines, pre-rewrite) is
> overwritten in place. There is no co-existence of old/new
> `session-turn.tsx`.

The hybrid opt-in shell violated that contract by trying to ship
new leaf components behind an opt-in mount point while keeping the
legacy `SessionTurn` as the default user-path. Per the in-thread
clarification this slice ships the full rewrite: the new shell
overwrites the existing `session-turn.tsx`, no parallel surface.

This commit removes:
- `session-turn-v2.tsx` + `.css` + `.test.ts`
- `ui.sessionTurnV2.*` i18n keys (en.ts / zh.ts)

The new leaf components themselves remain — they will be wired into
the rewritten `session-turn.tsx` in subsequent commits on this
branch, alongside the User/Assistant display extractions and the
message-timeline.tsx split.
The W1 visual lock and slice 11b.1 design doc §3.1 / DESIGN.md L482
reference two CSS tokens that were not yet present in theme.css:

  --fg-secondary  — reasoning summary ink, distinct from --fg-weak
                    so future rebalances can shift secondary alone
  --icon-chev     — trow-block chevron color, distinct from --icon-base

Both tokens are registered in theme.css (light + dark + @media first-
paint mirror) and mirrored in pawwork.json overrides so theme-parity.
test.ts stays GREEN (it enforces a 1:1 key+value sync between the two
sources).

Token values today are intentionally identical to the closest existing
neighbor (--fg-weak / --icon-base respectively); they ship as carve-
outs so the next visual rebalance can move them without touching
every callsite that referenced --fg-weak or --icon-base for an
unrelated purpose.

Switch the two leaf components that previously used the fallbacks
(`session-turn-agent-round.css` reasoning, `session-turn-trow-block.
css` chev) over to the real tokens and remove the `TODO(Phase 5)`
comments. Phase 5 token deferral noted in PR #589 Risk Notes is now
closed.
Slice 11b.1 step 1/14 of the message-part.tsx split mandated by
AGENTS.md (>500-line files must be split) and the slice design doc
(GPT-X-authored 14-file split plan).

Extracts the type-only declarations — `MessageProps`, `SessionAction`,
`UserActions`, `MessagePartProps`, `PartComponent`, `ToolProps`,
`ToolComponent` — into a new `message-part-types.ts` module. The
new file is a pure type module (no runtime exports, no JSX, no Solid
primitive use), so renderer/tool files can pull in the shared shapes
without dragging the heavier message-part.tsx transitive import graph.

`message-part.tsx` keeps its existing public API by re-exporting the
types verbatim, so `session-turn.tsx` / `message-part.stories.tsx` /
unit tests need no update in this step. The next slice will migrate
consumers to import from `./message-part-types` directly and retire
the re-exports.

Net: message-part.tsx loses ~35 declaration lines; types live in a
49-line file. Typecheck GREEN, 478 pass / 3 baseline fail unchanged.
Slice 11b.1 step 2/14 of the message-part.tsx split. Moves the part
and tool registries — the foundation that every renderer plugs into
through `PART_MAPPING[...] = ...` and `registerTool({...})` — out of
the 2400-line monolith into a 75-line registry module.

New file `message-part-registry.tsx` exports:
  - `PART_MAPPING`           the mutable part-type → renderer map
  - `registerPartComponent`  setter helper
  - `Part`                   dispatcher component (looks up the
                             renderer registered for a given part type)
  - `registerTool`           tool renderer registration
  - `getTool`                renderer lookup by tool name
  - `ToolRegistry`           legacy alias object kept for the
                             BasicTool / GenericTool callsites

`message-part.tsx` keeps the same public symbols through a re-export
block so existing callers (session-turn.tsx, stories, tests) don't
have to move yet — the migration to direct imports happens in the
next slice.

Net: message-part.tsx 2459 → 2399 lines. Typecheck GREEN, 478 pass /
3 baseline fail unchanged.
Slice 11b.1 step 3/14 of the message-part.tsx split. Moves the
24-ms paced text reveal hook (`createPacedValue`), the path helpers
(`isAbsoluteLikePath`, `joinWorkspacePath`, `relativizeProjectPath`,
`getDirectory`), and the `<MessageMarkdown>` / `<PacedMarkdown>`
wrappers into a new `message-part-markdown.tsx` module.

These helpers are consumed by the text / reasoning renderers (paced
streaming), the assistant message display (markdown body), and a
handful of tool renderers that show paths. Lifting them out lets each
consumer import the markdown stack directly without dragging the
full message-part dispatcher's transitive graph.

Behavior is unchanged: same 24-ms cadence, same 8-char whitespace
lookahead, same cleanup semantics on signal change / unmount. The
`useData()` access inside `<MessageMarkdown>` and `getDirectory()` is
preserved at the same call sites — these helpers must be invoked
inside Solid render scopes, never at module load.

message-part.tsx 2399 → 2263 lines (-136). Typecheck GREEN, 478 pass /
3 baseline fail unchanged.
Slice 11b.1 step 4/14 of the message-part.tsx split. Moves the i18n-
aware tool-info factory (`getToolInfo`) and the per-tool state
narrowing helpers (`toolStateMetadata`, `toolStateError`) out of the
monolith into `message-part-tool-info.ts`, alongside the agent tone
palette + `taskAgent` resolver that the agent/task tool renderer uses.

These helpers are all "pure (per render scope)" — `getToolInfo`
internally calls `useI18n()`, so it must remain inside a Solid render
scope; it has not become module-level callable.

The public `buildToolInfo` / `ToolInfo` re-export now flows from
`./tool-info` directly (its original source) rather than transiting
through message-part.tsx — equivalent behavior, smaller import graph
inside the new tool-info module.

message-part.tsx 2263 → 2103 lines (-160). Typecheck GREEN, 478 pass /
3 baseline fail unchanged.
Slice 11b.1 step 5/14 of the message-part.tsx split. Moves the
legacy assistant-pipeline helpers — list / latestDefined / same /
sameRef / sameGroup / sameGroups / PartRef / PartGroup / groupParts
(legacy) / index / renderable / partDefaultOpen / HIDDEN_TOOLS /
CONTEXT_GROUP_TOOLS / isContextGroupTool — into a dedicated module
`message-part-render-groups.ts`.

These are deliberately kept separate from the v2 grouping helper at
`./message-part-group.ts` (both files own a `groupParts` symbol; the
shapes differ — legacy emits context-tool groups, v2 emits prose /
reasoning / trow-block groups). Inside message-part.tsx the legacy
import is aliased to `legacyGroupParts` so the call sites stay
explicit about which grouping they consume.

The `message-part-stale.test.ts` source-grep that pinned
`latestDefined` to `message-part.tsx` is updated to assert it lives
in `message-part-render-groups.ts` (the new home) while still
checking that the assistant dispatcher in `message-part.tsx` reads
it. No behavioral change.

message-part.tsx 2103 → 1986 lines (-117). Typecheck GREEN, 478 pass /
3 baseline fail unchanged.
…extToolGroup

Slice 11b.1 step 6/14 of the message-part.tsx split. Moves the entire
legacy assistant rendering surface into a dedicated module:

  `assistant-message-display.tsx` (470 lines)
    - `AssistantMessageDisplay`  single-message dispatcher
    - `AssistantParts`           multi-message round renderer (used by
                                 the legacy SessionTurn shell)
    - `ContextToolGroup`         collapsible context-tool row
    - `ExaOutput`                links-only fallback for webfetch/search
    - private helpers: `urls`, `contextToolDetail`, `contextToolTrigger`,
      `contextToolSummary`

Back-compat preserved: `message-part.tsx` re-exports
`AssistantMessageDisplay` and `AssistantParts` so existing consumers
(session-turn.tsx imports `AssistantParts` directly) keep resolving;
`ExaOutput` is imported into message-part.tsx and used by the websearch
tool renderer that still lives here.

This is the larger of the two named extractions in design doc §1
("extracted from message-part.tsx L901"). The User display extraction
is the next step.

message-part.tsx 1986 → 1607 lines (-379). Typecheck GREEN, 478 pass /
3 baseline fail unchanged.
…body

AstroHan's second W1 retest flagged that 4 tool renderers (read /
find / web search / bash) still render their legacy sans / base /
large header chrome inside the trow body, breaking the W1 caption
surface. Rewriting every renderer is overkill and would re-introduce
the same drift the next time a tool is added.

Wrap each rich `renderTool` result in a single `trow-result-body`
slot inside `session-turn-leaf-bridges`, and scope the DESIGN.md L417
frame (transparent + 1px --border-weaker + radius-sm + 8/12 padding +
mono-small / fg-weak) to that slot in `session-turn-trow-block.css`.
Hard-coded sans/base/large typography on `basic-tool-tool-title` /
`-subtitle` / `-arg` and `message-part-title` / `-text` / `-filename`
is flattened to the W1 caption family only as a descendant of
`[data-slot="trow-result-body"]` — the reset boundary is the trow
result body wrapper, not the renderer registry, so new tools inherit
the caption typography automatically.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/ui/src/components/session-turn-trow-block.css`:
- Around line 21-29: The summary slot selector
[data-component="session-turn-trow-block"] [data-slot="trow-summary"] lacks
keyboard focus styles; add :focus and :focus-visible rules for that interactive
<summary> element to provide a visible focus indicator (e.g., outline or
box-shadow using your design token like --focus-ring or --accent-focus, and
preserve border-radius and padding), ensure the styles only show on keyboard
focus via :focus-visible if available, and keep cursor and other layout
properties unchanged so keyboard users can clearly see which trow-summary is
focused.
- Line 25: Change the cursor style for the interactive summary element from a
non-interactive pointer to a clickable affordance: replace the rule that sets
"cursor: default" for the summary selector (the CSS rule that currently contains
"cursor: default;") with "cursor: pointer;" so the <summary> element shows a
pointer on hover and signals it is interactive.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a508779c-6b6a-44d1-a4f0-48bc64b216e7

📥 Commits

Reviewing files that changed from the base of the PR and between e177559 and 90e3903.

📒 Files selected for processing (13)
  • packages/app/src/pages/session/message-timeline.tsx
  • packages/app/src/pages/session/session-timeline-scroll-controller.ts
  • packages/app/src/pages/session/use-session-timeline-interaction.ts
  • packages/ui/src/components/session-turn-agent-round-helpers.ts
  • packages/ui/src/components/session-turn-agent-round.css
  • packages/ui/src/components/session-turn-agent-round.test.ts
  • packages/ui/src/components/session-turn-agent-round.tsx
  • packages/ui/src/components/session-turn-leaf-bridges.tsx
  • packages/ui/src/components/session-turn-trow-block.css
  • packages/ui/src/components/session-turn-user-bubble.css
  • packages/ui/src/components/session-turn-user-bubble.test.ts
  • packages/ui/src/components/session-turn-user-bubble.tsx
  • packages/ui/src/hooks/create-auto-scroll.tsx
🚧 Files skipped from review as they are similar to previous changes (6)
  • packages/ui/src/components/session-turn-agent-round.test.ts
  • packages/ui/src/components/session-turn-user-bubble.test.ts
  • packages/ui/src/components/session-turn-agent-round.tsx
  • packages/ui/src/components/session-turn-leaf-bridges.tsx
  • packages/ui/src/components/session-turn-user-bubble.tsx
  • packages/ui/src/components/session-turn-agent-round.css

Comment on lines +21 to +29
[data-component="session-turn-trow-block"] [data-slot="trow-summary"] {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: default;
list-style: none;
color: var(--fg-base);
font: var(--type-body);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add keyboard focus styles for accessibility.

The PR objectives explicitly call out "keyboard a11y" as a key feature, but no :focus or :focus-visible styles are defined for the interactive <summary> element. Keyboard users need visible focus indicators to navigate the interface effectively, and WCAG 2.1 Success Criterion 2.4.7 requires visible focus.

⌨️ Suggested focus styles
 [data-component="session-turn-trow-block"] [data-slot="trow-summary"] {
   display: inline-flex;
   align-items: center;
   gap: 8px;
   cursor: default;
   list-style: none;
   color: var(--fg-base);
   font: var(--type-body);
 }
+
+[data-component="session-turn-trow-block"] [data-slot="trow-summary"]:focus-visible {
+  outline: 2px solid var(--focus-ring, `#0066cc`);
+  outline-offset: 2px;
+  border-radius: 4px;
+}

Note: Adjust the focus ring color token to match your design system's focus indicator token.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/session-turn-trow-block.css` around lines 21 - 29,
The summary slot selector [data-component="session-turn-trow-block"]
[data-slot="trow-summary"] lacks keyboard focus styles; add :focus and
:focus-visible rules for that interactive <summary> element to provide a visible
focus indicator (e.g., outline or box-shadow using your design token like
--focus-ring or --accent-focus, and preserve border-radius and padding), ensure
the styles only show on keyboard focus via :focus-visible if available, and keep
cursor and other layout properties unchanged so keyboard users can clearly see
which trow-summary is focused.

display: inline-flex;
align-items: center;
gap: 8px;
cursor: default;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Change cursor to pointer for interactive summary element.

The <summary> element is inherently interactive (toggles the <details> expansion), but cursor: default makes it appear non-interactive to users. Standard practice is to use cursor: pointer for clickable elements to signal affordance.

🖱️ Proposed fix
-  cursor: default;
+  cursor: pointer;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cursor: default;
cursor: pointer;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/session-turn-trow-block.css` at line 25, Change
the cursor style for the interactive summary element from a non-interactive
pointer to a clickable affordance: replace the rule that sets "cursor: default"
for the summary selector (the CSS rule that currently contains "cursor:
default;") with "cursor: pointer;" so the <summary> element shows a pointer on
hover and signals it is interactive.

Astro-Han added 2 commits May 13, 2026 11:49
AstroHan's third W1 retest (msg=362e9b72) reported that the light
mode user bubble also loses its shape — `--bg-cream` (#fdf6e3) and
`--bg-base` (#fbf3df) sit ~3 RGB apart, an equally thin separation
for the brighter end of the luminance curve as dark's ~5 RGB gap.
The previous slice 11b.1 fix added a 1px inset ring in dark only on
the assumption that light had enough luminance delta to read; the
retest disproves that.

Apply the hairline in both modes. Light uses `--border-weaker` (the
dimmer of the two tokens) because the high-luminance side has more
edge sensitivity and `--border-weak` would shout; dark stays on
`--border-weak`. The base rule is the light hairline; the dark
override flips it to `--border-weak`. Geometry is unchanged —
`box-shadow: inset` does not affect layout.
AstroHan's third W1 retest (msg=362e9b72) raised two visual issues
on the trow block; both land in `session-turn-trow-block.css`.

**#2 — per-tool box wrapper.** Slice 11b.1 #5 B+ added a transparent
+ 1px --border-weaker frame around each tool's rich body
(`data-slot="trow-result-body"`) as the L417-spec scope marker.
AstroHan reported the chrome makes tools look like isolated cards
and (when bash is expanded) stacks on top of bash-output's own
border. The W1 intent is a flat stacked feed where tools 叠在一起.
The wrapper stays — it is still the scope boundary for the inner
chrome typography reset — but its visual chrome (border / radius /
padding) is removed.

**#4 — chev direction + inner tool chev size.** Two parts:

  (a) Trow summary chev direction. The third retest re-defined the
      affordance: 折叠朝右、展开朝下. The second-retest fix that
      pointed the chev up on `details[open]` is reverted —
      file-tree, Collapsible, and the W1 mocks all use down-when-
      open, so trow should match. Default `transform: rotate(-90deg)`
      (chevron-down → right when collapsed); `details[open]` flips to
      `rotate(0)` (natural down).

  (b) Inner Collapsible.Arrow chev size inside the trow result body.
      AstroHan flagged that the chev inside each tool trigger ("执行
      命令 XXXX" row) renders as a "大 chevron" — Collapsible's default
      is 16px, but the W1 caption surface (DESIGN.md L412 lock) caps
      the chev at 12px. Scoped reset fires only inside
      `[data-slot="trow-result-body"]`, so the Collapsible default
      elsewhere (file-tree, settings, etc.) is unaffected.
AstroHan's third W1 retest (msg=362e9b72) flagged that the "思考中"
row at the agent bottom drifts far from the rest of the round when
reasoning / trow / prose is already present — it lands ~12px below
the assistant-content block as an isolated tail, duplicating the
liveness signal the TrowBlock shimmer / streaming prose / working-
time tick already provide. GPT-X reviewed (msg=22179280) and the
three-question read came out: less is enough, the placeholder is
only useful before any visible part exists; keep it as a pre-first-
visible-output affordance, drop it from the steady-state.

Tighten `showThinking` to `working && !error && status.type !==
"retry" && assistantVisible === 0`. The previous condition leaked in
the `showReasoningSummaries: false` branch where the gate fell back
to `return true` regardless of visibility — which is exactly the
state AstroHan tested in. The new gate applies the
`assistantVisible === 0` clamp uniformly; reasoning parts still
register as "not visible" when the summary toggle is off, so the
existing "show thinking while reasoning streams under the toggle"
behaviour is preserved.

Position alignment ("对齐 agent prose 起点") falls out of the
tightening — once the row only renders when no visible part exists,
the assistant-content block above it has an empty agent-body, so
the 12px gap no longer reads as a detached tail. The placeholder
sits at the column's natural left edge (same as where prose would
start), inheriting `session-turn-message-container`'s flex-start
alignment. No structural move needed.

The "Tool Display Spec" follow-up that would let the placeholder
merge into the agent working-time header is filed as issue #596;
that work is out of scope for slice 11b.1.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
packages/ui/src/components/session-turn-trow-block.css (1)

21-29: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Still missing interactive affordance and keyboard focus styling on the summary trigger.

Line 25 keeps cursor: default, and the interactive summary has no visible keyboard focus state. This remains an accessibility/usability gap.

♿ Minimal fix
 [data-component="session-turn-trow-block"] [data-slot="trow-summary"] {
   display: inline-flex;
   align-items: center;
   gap: 8px;
-  cursor: default;
+  cursor: pointer;
   list-style: none;
   color: var(--fg-base);
   font: var(--type-body);
 }
+
+[data-component="session-turn-trow-block"] [data-slot="trow-summary"]:focus-visible {
+  outline: 2px solid currentColor;
+  outline-offset: 2px;
+  border-radius: 4px;
+}
#!/bin/bash
# Verify whether summary trigger has focus-visible styles and interactive cursor affordance.
rg -n '\[data-slot="trow-summary"\].*cursor:\s*default|cursor:\s*pointer' packages/ui/src/components/session-turn-trow-block.css -A3 -B3
rg -n '\[data-slot="trow-summary"\]:(focus|focus-visible)' packages/ui/src/components/session-turn-trow-block.css -A2 -B2
rg -n '<summary|data-slot="trow-summary"' packages/ui/src/components/session-turn-trow-block.tsx -A3 -B3
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/session-turn-trow-block.css` around lines 21 - 29,
The summary trigger [data-slot="trow-summary"] is styled with cursor: default
and has no keyboard focus visuals; update its CSS to use an interactive cursor
(cursor: pointer) and add a focus/focus-visible rule (e.g.,
[data-slot="trow-summary"]:focus-visible) that applies a visible
outline/box-shadow and preserves accessible contrast, ensuring the selector in
session-turn-trow-block.css targets the summary trigger and its focus state so
keyboard users can see when it’s focused.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@packages/ui/src/components/session-turn-trow-block.css`:
- Around line 21-29: The summary trigger [data-slot="trow-summary"] is styled
with cursor: default and has no keyboard focus visuals; update its CSS to use an
interactive cursor (cursor: pointer) and add a focus/focus-visible rule (e.g.,
[data-slot="trow-summary"]:focus-visible) that applies a visible
outline/box-shadow and preserves accessible contrast, ensuring the selector in
session-turn-trow-block.css targets the summary trigger and its focus state so
keyboard users can see when it’s focused.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 773de814-400c-4774-8b5b-66e239736ae6

📥 Commits

Reviewing files that changed from the base of the PR and between 90e3903 and b48a860.

📒 Files selected for processing (3)
  • packages/ui/src/components/session-turn-trow-block.css
  • packages/ui/src/components/session-turn-user-bubble.css
  • packages/ui/src/components/session-turn.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/ui/src/components/session-turn.tsx

Astro-Han added 4 commits May 13, 2026 12:17
… WebFetch parity

AstroHan's fourth W1 retest (msg=ac13481a) flagged three trow visuals.

**#1 — Missing expand/collapse animation.** The header comment on
`session-turn-trow-block.css` claimed `session-turn.css` owned the
`::details-content` rule, but it was never actually wired anywhere.
Add the rule (mirror of preview `message-flow.html` L13-23): height +
opacity transition gated by `prefers-reduced-motion: no-preference`,
with `transition-behavior: allow-discrete` covering the discrete
`content-visibility` step. The `:root { interpolate-size:
allow-keywords }` declaration in utilities.css is what lets
`height: 0` ↔ `height: auto` interpolate; the rule silently no-ops to
a snap without it. Inner Collapsible.Content gets a matching scoped
animation — Solid's `collapsible.css` ships its keyframes commented
out (L92-101), so without the override the per-tool body would snap
open. The scoped selector keeps the file-tree / settings Collapsibles
on their default behaviour.

**#2 — Tighter expanded body.** AstroHan reported the expanded trow
body felt too loose vs preview. `.mf-tlist` in
`docs/design/preview/message-flow.html` L199 locks gap at 6px between
sub-tool rows; trow-body now matches (was 8px).

**#3 — WebFetch layout parity.** WebFetch is the only tool that ships
a right-aligned `[data-component="tool-action"]` arrow inside its
trigger row (`message-part-tools-basic.tsx` L142). The other tools
use the plain `trigger={{ title, subtitle }}` object form, so they
already flow left without the action chrome. Suppress the action icon
only inside the trow result body — standalone WebFetch outside the
trow keeps the affordance.
…te thinking heading

Two related AstroHan W1 retest 4 issues (msg=ac13481a) on the
"思考中" surface and the reasoning-summaries setting.

**#4 — Thinking heading overflows right edge.** When
`showReasoningSummaries` is off the thinking row renders a
`<TextReveal>` showing the latest reasoning heading. `TextReveal`
defaults to `white-space: nowrap` on its entering/leaving spans, so
a long heading bleeds past the column's right edge. The component
already exposes a `truncate` prop that caps width and adds
`text-overflow: ellipsis`; opt in.

**#5a — Reasoning-summaries setting had no effect on COT body.**
The settings toggle ("显示推理摘要 / Show reasoning summaries") flows
through to `SessionTurn` but only gated `showThinking`'s assistant-
visible counting and the secondary reasoning heading inside the
thinking row. `AgentRound` never received the prop, so reasoning
groups always rendered regardless of the toggle. Pass
`showReasoningSummaries` to `AgentRound` and drop `reasoning` groups
from the rendered output when it is explicitly `false`. The grouper
itself stays structural (does not know about user preferences); the
filter lives at the leaf consuming the groups, matching the existing
seam with `partState()` in `session-turn-helpers.ts`.
W1 trow group wraps every tool body, default-collapsed. The legacy
"Expand shell tool parts" / "Expand edit tool parts" settings mapped
`defaultOpen` onto each per-tool Part, but in W1 that prop only changes
state inside an already-hidden body — the toggles silently did nothing
until the user expanded the outer trow first, so they read as broken.

Removes the two SettingsRow controls + i18n keys (en/zh) and stops
threading `shellToolDefaultOpen` / `editToolDefaultOpen` through the
timeline → SessionTurn → AgentRound → TrowBlock chain, plus the
LeafBridge `defaultOpen` fall-through (which also accidentally landed
on every non-bash tool, not just edit / write / patch). The
persistence keys `general.shellToolPartsExpanded` /
`editToolPartsExpanded` are left in place on the settings context for
migration compat with users who already have the value in
localStorage; reintroducing a tool-expansion preference later should
ship under a W1-shaped name.
…tent

W1 retest 4 reproduced the off-bottom round → next agent
`content_resize` → viewport snaps to bottom (or, for a round far from
bottom, to the user-turn top via the message-level anchor restore).
RCA narrows the trigger to the trow `<details>` toggle: it changes the
scroll geometry around the round but the scroll controller never
treated it as a user-layout intent, so the next content_resize fell
through `following_latest` and called `restore_latest`.

Minimal fix threads a `layout_interaction` intent through the same
channel scroll gestures already use:
- `<TrowBlock>` summary fires `onUserLayoutInteraction` on
  `onPointerDown` (and Enter / Space keydown), *before* the native
  `<details>` toggle.
- `<SessionTurnAgentRound>` / `<SessionTurn>` / `TimelineMessage` /
  `<MessageTimeline>` forward the callback.
- `MessageTimeline` translates the callback into
  `onTimelineScrollIntent({ type: "layout_interaction", source:
  "trow_toggle" })`.
- `session-timeline-scroll-controller` accepts the new intent type,
  flips `state.mode = "reading_history"`, drops the latest-protected
  latch, and emits with `reason: "layout_interaction_reading"` and no
  recovery so the next `content_resize` leaves the viewport alone.
- `use-session-timeline-interaction` pauses the autoScroll owner and
  cancels the bottom-follow lock for `layout_interaction`, matching
  the existing scroll-gesture branch.

Adds a regression e2e (`session-w1-scroll-layout.spec.ts`): stages a
long trow + off-bottom scroll, clicks the trow summary, triggers an
agent append, and asserts the viewport stayed inside a "reading"
range — neither snapped to user-turn top nor pinned flush against the
bottom edge.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/app/e2e/session/session-w1-scroll-layout.spec.ts`:
- Around line 31-35: The test title string in the test(...) call is marker-heavy
and not following the repo style; replace the current title "@regression P0 `#6` —
trow toggle + agent append keeps viewport in reading position" with a lowercase,
behavior-first descriptive name (e.g., "trow toggle and agent append preserve
viewport reading position") by editing the test(...) invocation in
session-w1-scroll-layout.spec.ts so the test string is concise, lowercase, and
focused on the behavior.
- Around line 80-82: The test uses a fixed wall-clock wait via
page.waitForTimeout(800) which makes it flaky; replace it by observing DOM state
(e.g., measure scrollHeight before appending and then wait until it increases)
using Playwright's expect.poll or expect with page.evaluate. Concretely: capture
previousHeight = await page.evaluate(() =>
document.querySelector('<your-scroll-container-selector>').scrollHeight)
immediately before the append/operation, remove the page.waitForTimeout(800)
call, and add an assertion like await expect.poll(async () => await
page.evaluate(() =>
document.querySelector('<your-scroll-container-selector>').scrollHeight)).toBeGreaterThan(previousHeight)
(or use expect with a timeout) to wait deterministically; replace
'<your-scroll-container-selector>' with the actual selector used in this spec
and remove the fixed timeout call.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 68e97c82-763e-46a4-ac94-1d0bada20835

📥 Commits

Reviewing files that changed from the base of the PR and between b48a860 and b2ee6cb.

📒 Files selected for processing (19)
  • packages/app/e2e/session/session-renderer-diagnostics.spec.ts
  • packages/app/e2e/session/session-w1-bubble.spec.ts
  • packages/app/e2e/session/session-w1-error.spec.ts
  • packages/app/e2e/session/session-w1-events.spec.ts
  • packages/app/e2e/session/session-w1-scroll-layout.spec.ts
  • packages/app/e2e/session/session-w1-trow.spec.ts
  • packages/app/src/components/settings-general.tsx
  • packages/app/src/i18n/en.ts
  • packages/app/src/i18n/zh.ts
  • packages/app/src/pages/session/message-timeline-row.tsx
  • packages/app/src/pages/session/message-timeline.tsx
  • packages/app/src/pages/session/session-timeline-scroll-controller.ts
  • packages/app/src/pages/session/use-session-timeline-interaction.ts
  • packages/ui/src/components/session-turn-agent-round.tsx
  • packages/ui/src/components/session-turn-leaf-bridges.tsx
  • packages/ui/src/components/session-turn-trow-block.css
  • packages/ui/src/components/session-turn-trow-block.tsx
  • packages/ui/src/components/session-turn.tsx
  • packages/ui/src/components/timeline-playground.stories.tsx
💤 Files with no reviewable changes (3)
  • packages/ui/src/components/timeline-playground.stories.tsx
  • packages/app/src/i18n/zh.ts
  • packages/app/src/i18n/en.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/app/e2e/session/session-renderer-diagnostics.spec.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • packages/app/e2e/session/session-w1-error.spec.ts
  • packages/app/e2e/session/session-w1-trow.spec.ts
  • packages/app/e2e/session/session-w1-events.spec.ts
  • packages/app/e2e/session/session-w1-bubble.spec.ts
  • packages/app/src/pages/session/message-timeline.tsx

Comment on lines +31 to +35
test("@regression P0 #6 — trow toggle + agent append keeps viewport in reading position", async ({
page,
llm,
project,
}) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use a lowercase, descriptive test name.

At Line 31, the test title is marker-heavy (@regression P0 #6 ...) and not in the repo’s lowercase descriptive style. Rename to a behavior-first title.

Suggested rename
-test("@regression P0 `#6` — trow toggle + agent append keeps viewport in reading position", async ({
+test("trow toggle and agent append keep viewport in reading position", async ({

As per coding guidelines: "Use lowercase, descriptive test names (e.g., 'sidebar can be toggled')".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test("@regression P0 #6 — trow toggle + agent append keeps viewport in reading position", async ({
page,
llm,
project,
}) => {
test("trow toggle and agent append keep viewport in reading position", async ({
page,
llm,
project,
}) => {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/app/e2e/session/session-w1-scroll-layout.spec.ts` around lines 31 -
35, The test title string in the test(...) call is marker-heavy and not
following the repo style; replace the current title "@regression P0 `#6` — trow
toggle + agent append keeps viewport in reading position" with a lowercase,
behavior-first descriptive name (e.g., "trow toggle and agent append preserve
viewport reading position") by editing the test(...) invocation in
session-w1-scroll-layout.spec.ts so the test string is concise, lowercase, and
focused on the behavior.

Comment on lines +80 to +82
// Give the renderer a moment to apply the content_resize.
await page.waitForTimeout(800)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Replace fixed timeout with observable-state waiting.

At Line 81, page.waitForTimeout(800) makes this regression flaky under variable render timing. Wait on measurable state (e.g., scroll height change after append).

Suggested stabilization
-  // Give the renderer a moment to apply the content_resize.
-  await page.waitForTimeout(800)
+  // Wait until appended content causes a measurable layout change.
+  await expect
+    .poll(async () => {
+      const m = await scroller.evaluate((el) => ({
+        scrollHeight: el.scrollHeight,
+        scrollTop: el.scrollTop,
+      }))
+      return m.scrollHeight
+    })
+    .toBeGreaterThan(before.scrollHeight)

As per coding guidelines: "Never use wall-clock waits like page.waitForTimeout(...) to make a test pass" and "Wait on observable state with expect(...)/expect.poll(...)".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Give the renderer a moment to apply the content_resize.
await page.waitForTimeout(800)
// Wait until appended content causes a measurable layout change.
await expect
.poll(async () => {
const m = await scroller.evaluate((el) => ({
scrollHeight: el.scrollHeight,
scrollTop: el.scrollTop,
}))
return m.scrollHeight
})
.toBeGreaterThan(before.scrollHeight)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/app/e2e/session/session-w1-scroll-layout.spec.ts` around lines 80 -
82, The test uses a fixed wall-clock wait via page.waitForTimeout(800) which
makes it flaky; replace it by observing DOM state (e.g., measure scrollHeight
before appending and then wait until it increases) using Playwright's
expect.poll or expect with page.evaluate. Concretely: capture previousHeight =
await page.evaluate(() =>
document.querySelector('<your-scroll-container-selector>').scrollHeight)
immediately before the append/operation, remove the page.waitForTimeout(800)
call, and add an assertion like await expect.poll(async () => await
page.evaluate(() =>
document.querySelector('<your-scroll-container-selector>').scrollHeight)).toBeGreaterThan(previousHeight)
(or use expect with a timeout) to wait deterministically; replace
'<your-scroll-container-selector>' with the actual selector used in this spec
and remove the fixed timeout call.

Astro-Han added 2 commits May 13, 2026 13:50
Each per-tool row inside the trow result body is wrapped in Solid
Collapsible's `<Collapsible.Trigger>`, which carries a hardcoded
`height: 30px` (collapsible.css L19) + a 26x26 arrow wrapper. The
30px floor overrode the 18px natural mono-small line height the
preview spec (message-flow.html L200-208) and the screenshot
evidence (W1 retest 5 msg=34822d84 attachment) demanded; row stride
was effectively 30 + 6 = 36px regardless of `gap: 6` on `trow-body`.

Scoped reset inside `[data-slot="trow-result-body"]` flattens the
trigger to `height: auto; min-height: 0` and shrinks the arrow
wrapper to 12x12 (matching the chev icon scoped reset that already
shipped). Collapsible elsewhere (file-tree, settings, every other
trigger) keeps the 30px default because the resets only fire as
descendants of the trow result body scope. Row stride drops from
~36px to ~24px.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/app/src/pages/session/use-session-timeline-interaction.ts (1)

258-264: 💤 Low value

Dead createEffect with a no-op handler.

This effect tracks sessionID, the last visible user message id, and historyWindow.turnStart(), but its handler is () => undefined and defer: true skips the initial tick. It produces no observable behavior and reads three reactive sources for nothing — it looks like a leftover from the previous bottom-follow-lock flow that this PR removed.

♻️ Proposed cleanup
-  createEffect(
-    on(
-      () => [input.sessionID(), input.visibleUserMessages().at(-1)?.id, historyWindow.turnStart()] as const,
-      () => undefined,
-      { defer: true },
-    ),
-  )

If this effect was meant to host follow-up logic in a later slice, consider adding a TODO comment referencing the slice so it doesn't read as dead code.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/app/src/pages/session/use-session-timeline-interaction.ts` around
lines 258 - 264, The createEffect using on(...) with dependencies
input.sessionID(), input.visibleUserMessages().at(-1)?.id and
historyWindow.turnStart() is a no-op (handler is () => undefined and defer:
true) and should be removed or annotated; either delete the entire createEffect
block (since it only reads reactive sources and has no behavior), or replace the
handler with a brief TODO comment referencing the follow-up slice and why these
specific signals are tracked (so future work knows this was intentional). Locate
the createEffect/on call in use-session-timeline-interaction.ts and apply one of
these two fixes to eliminate the dead reactive read.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/app/src/pages/session/session-timeline-scroll-anchors.ts`:
- Around line 116-126: In restoreLatest, the sentinel branch is effectively dead
because viewport.scrollTop is set with Math.max(sentinelTop, maxScrollTop) which
always yields maxScrollTop; change the assignment to use Math.min(sentinelTop,
maxScrollTop) so the scroll positions places the bottomSentinel flush with the
viewport bottom but never exceeds the maximum scrollable position (update the
line setting viewport.scrollTop inside restoreLatest that currently uses
Math.max to use Math.min instead).

In `@packages/app/src/pages/session/session-timeline-scroll-controller.ts`:
- Around line 519-533: The nearBottom branch of observe currently assigns
state.lastSafePosition to latest unconditionally, which can overwrite a
target_message anchor; update the nearBottom handling in observe so it does not
change state.lastSafePosition when the controller is currently targeting a
message whose lastSafePosition.kind === "target_message" (i.e., if state.mode
=== "targeting_message" && state.lastSafePosition?.kind === "target_message"
then skip updating lastSafePosition), but still perform the existing mode/
latestProtected updates and return the same result; this preserves the invariant
used by the target restoration logic and aligns with
updateObservedSafePosition's intent.

---

Nitpick comments:
In `@packages/app/src/pages/session/use-session-timeline-interaction.ts`:
- Around line 258-264: The createEffect using on(...) with dependencies
input.sessionID(), input.visibleUserMessages().at(-1)?.id and
historyWindow.turnStart() is a no-op (handler is () => undefined and defer:
true) and should be removed or annotated; either delete the entire createEffect
block (since it only reads reactive sources and has no behavior), or replace the
handler with a brief TODO comment referencing the follow-up slice and why these
specific signals are tracked (so future work knows this was intentional). Locate
the createEffect/on call in use-session-timeline-interaction.ts and apply one of
these two fixes to eliminate the dead reactive read.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: dbb5c289-e9b4-4890-b032-b85301d69bfb

📥 Commits

Reviewing files that changed from the base of the PR and between b2ee6cb and 9e071d2.

📒 Files selected for processing (17)
  • packages/app/src/pages/session/message-timeline-row.tsx
  • packages/app/src/pages/session/message-timeline.tsx
  • packages/app/src/pages/session/session-timeline-scroll-anchors.ts
  • packages/app/src/pages/session/session-timeline-scroll-controller.test.ts
  • packages/app/src/pages/session/session-timeline-scroll-controller.ts
  • packages/app/src/pages/session/use-session-hash-scroll.test.ts
  • packages/app/src/pages/session/use-session-scroll-dock.test.ts
  • packages/app/src/pages/session/use-session-scroll-dock.ts
  • packages/app/src/pages/session/use-session-timeline-interaction.ts
  • packages/ui/src/components/session-turn-agent-round.css
  • packages/ui/src/components/session-turn-agent-round.test.ts
  • packages/ui/src/components/session-turn-agent-round.tsx
  • packages/ui/src/components/session-turn-leaf-bridges.tsx
  • packages/ui/src/components/session-turn-parent.test.ts
  • packages/ui/src/components/session-turn-trow-block.css
  • packages/ui/src/components/session-turn-trow-block.tsx
  • packages/ui/src/components/session-turn.tsx
🚧 Files skipped from review as they are similar to previous changes (9)
  • packages/app/src/pages/session/message-timeline-row.tsx
  • packages/ui/src/components/session-turn-trow-block.tsx
  • packages/ui/src/components/session-turn-agent-round.css
  • packages/ui/src/components/session-turn-agent-round.test.ts
  • packages/ui/src/components/session-turn-trow-block.css
  • packages/ui/src/components/session-turn.tsx
  • packages/ui/src/components/session-turn-agent-round.tsx
  • packages/app/src/pages/session/message-timeline.tsx
  • packages/ui/src/components/session-turn-leaf-bridges.tsx

Comment on lines 116 to 126
function restoreLatest(viewport: HTMLElement, bottomSentinel?: HTMLElement | null) {
const maxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
if (bottomSentinel) {
const viewportRect = viewport.getBoundingClientRect()
const sentinelRect = bottomSentinel.getBoundingClientRect()
viewport.scrollTop = Math.max(0, viewport.scrollTop + sentinelRect.bottom - viewportRect.bottom)
const sentinelTop = Math.max(0, viewport.scrollTop + sentinelRect.bottom - viewportRect.bottom)
viewport.scrollTop = Math.max(sentinelTop, maxScrollTop)
return
}
viewport.scrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
viewport.scrollTop = maxScrollTop
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find callers/tests of restoreLatest / restoreTimelineSafePosition that exercise the sentinel branch
rg -nP --type=ts -C3 '\brestoreTimelineSafePosition\s*\(' 
rg -nP --type=ts -C3 'data-timeline-bottom-sentinel'

Repository: Astro-Han/pawwork

Length of output: 7756


🏁 Script executed:

cat -n packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts | head -250

Repository: Astro-Han/pawwork

Length of output: 8357


🏁 Script executed:

cat -n packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts | tail -100

Repository: Astro-Han/pawwork

Length of output: 3385


🏁 Script executed:

cat -n packages/app/src/pages/session/session-timeline-scroll-anchors.ts | sed -n '100,200p'

Repository: Astro-Han/pawwork

Length of output: 4621


🏁 Script executed:

grep -n "bottomSentinel" packages/app/src/pages/session/session-timeline-scroll-anchors.test.ts

Repository: Astro-Han/pawwork

Length of output: 43


🏁 Script executed:

cat -n packages/app/src/pages/session/use-session-timeline-interaction.ts | sed -n '190,210p'

Repository: Astro-Han/pawwork

Length of output: 920


🏁 Script executed:

git log --oneline -n 20 -- packages/app/src/pages/session/session-timeline-scroll-anchors.ts

Repository: Astro-Han/pawwork

Length of output: 126


🏁 Script executed:

cat -n packages/app/src/pages/session/message-timeline.tsx | sed -n '465,475p'

Repository: Astro-Han/pawwork

Length of output: 495


Math.max makes the sentinel branch dead code — change to Math.min.

With viewport.scrollTop = Math.max(sentinelTop, maxScrollTop):

  • If sentinelTop ≤ maxScrollTop: result is maxScrollTop
  • If sentinelTop > maxScrollTop: assigned sentinelTop, but browser clamps to maxScrollTop

Either way, viewport.scrollTop becomes maxScrollTop, which is identical to the no-sentinel fallback. The sentinel-aware computation never affects behavior.

The intended logic should scroll to the position placing the bottom sentinel flush with the viewport bottom, but never past the maximum scrollable position. That requires Math.min(sentinelTop, maxScrollTop), not Math.max.

Note: This code path is used in production (use-session-timeline-interaction.ts:194, 207) but has no test coverage.

🐛 Proposed fix
 function restoreLatest(viewport: HTMLElement, bottomSentinel?: HTMLElement | null) {
   const maxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
   if (bottomSentinel) {
     const viewportRect = viewport.getBoundingClientRect()
     const sentinelRect = bottomSentinel.getBoundingClientRect()
     const sentinelTop = Math.max(0, viewport.scrollTop + sentinelRect.bottom - viewportRect.bottom)
-    viewport.scrollTop = Math.max(sentinelTop, maxScrollTop)
+    viewport.scrollTop = Math.min(sentinelTop, maxScrollTop)
     return
   }
   viewport.scrollTop = maxScrollTop
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/app/src/pages/session/session-timeline-scroll-anchors.ts` around
lines 116 - 126, In restoreLatest, the sentinel branch is effectively dead
because viewport.scrollTop is set with Math.max(sentinelTop, maxScrollTop) which
always yields maxScrollTop; change the assignment to use Math.min(sentinelTop,
maxScrollTop) so the scroll positions places the bottomSentinel flush with the
viewport bottom but never exceeds the maximum scrollable position (update the
line setting viewport.scrollTop inside restoreLatest that currently uses
Math.max to use Math.min instead).

Comment on lines 519 to 533
if (observation.type === "scroll_sample") {
if (observation.metrics.nearBottom) {
updateObservedSafePosition(state, observation.safePosition ?? { kind: "latest" })
if (state.lastIntent && isExplicitBottomIntent(state.lastIntent)) {
state.lastSafePosition = observation.safePosition?.kind === "latest" ? observation.safePosition : { kind: "latest" }
if (state.mode !== "targeting_message") {
state.mode = "following_latest"
state.latestProtected = true
return result({
before,
observation,
accepted: true,
recovery: noRecovery,
reason: "explicit_bottom_navigation",
})
state.latestProtected = false
}
return result({
before,
observation,
accepted: true,
recovery: noRecovery,
reason: "explicit_bottom_navigation",
})
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm there's no other branch in observe() that re-establishes the target anchor after this overwrite.
ast-grep --pattern $'observe(observation) {
  $$$
}'
rg -nP --type=ts -C2 'lastSafePosition\s*='

Repository: Astro-Han/pawwork

Length of output: 2935


🏁 Script executed:

#!/bin/bash
# 1. Check the condition at line 610 and surrounding context
sed -n '600,620p' packages/app/src/pages/session/session-timeline-scroll-controller.ts

# 2. Check updateObservedSafePosition function (lines 318-332)
sed -n '318,340p' packages/app/src/pages/session/session-timeline-scroll-controller.ts

# 3. Get line count of the file to understand structure
wc -l packages/app/src/pages/session/session-timeline-scroll-controller.ts

Repository: Astro-Han/pawwork

Length of output: 1559


🏁 Script executed:

#!/bin/bash
# 4. Look at the observe function to find all branches that handle lastSafePosition after line 521
# Search for any reassignments of lastSafePosition or calls to updateObservedSafePosition after line 521
sed -n '500,650p' packages/app/src/pages/session/session-timeline-scroll-controller.ts | cat -n

Repository: Astro-Han/pawwork

Length of output: 6319


🏁 Script executed:

#!/bin/bash
# 5. Check the test file to see if the described test exists and what scenarios are covered
find packages/app -name "*scroll-controller.test.ts" -o -name "*scroll-controller.spec.ts"

Repository: Astro-Han/pawwork

Length of output: 135


🏁 Script executed:

#!/bin/bash
# Check if the test "target message anchor is not overwritten by ordinary scroll samples" exists
# and what it tests
rg -A 15 "target message anchor is not overwritten" packages/app/src/pages/session/session-timeline-scroll-controller.test.ts

Repository: Astro-Han/pawwork

Length of output: 505


🏁 Script executed:

#!/bin/bash
# Look for any existing test involving nearBottom and targeting_message
rg -B 2 -A 10 "nearBottom" packages/app/src/pages/session/session-timeline-scroll-controller.test.ts | head -50

Repository: Astro-Han/pawwork

Length of output: 692


scroll_sample nearBottom branch overwrites the target anchor even in targeting_message mode.

Line 521 unconditionally writes state.lastSafePosition to { kind: "latest" } (or the supplied latest safePosition). The mode guard on lines 522–525 protects mode and latestProtected, but lastSafePosition is updated for all modes — including targeting_message.

This breaks the invariant the rest of observe relies on:

  • Line 111 requires state.mode === "targeting_message" && state.lastSafePosition.kind === "target_message" to restore the target on window_changed / dock_resize / content_resize. Once the kind has been demoted to "latest", that branch is dead and observations fall through to the generic "weak_scroll_observed" path at line 135, so the target anchor is silently lost.
  • updateObservedSafePosition (lines 323–332) deliberately refuses to overwrite a target_message anchor with a non-target sample. The new nearBottom branch bypasses that guard.

Repro: user navigates to a message that is rendered near the bottom; the next scroll_sample arrives with nearBottom; subsequent agent-append content_resize / window_changed events no longer restore the target. The existing "target message anchor is not overwritten by ordinary scroll samples" test only exercises middleMetrics and won't catch this.

🐛 Proposed fix
       if (observation.type === "scroll_sample") {
         if (observation.metrics.nearBottom) {
-          state.lastSafePosition = observation.safePosition?.kind === "latest" ? observation.safePosition : { kind: "latest" }
-          if (state.mode !== "targeting_message") {
+          if (state.mode !== "targeting_message") {
+            state.lastSafePosition =
+              observation.safePosition?.kind === "latest" ? observation.safePosition : { kind: "latest" }
             state.mode = "following_latest"
             state.latestProtected = false
           }
           return result({
             before,
             observation,
             accepted: true,
             recovery: noRecovery,
             reason: "explicit_bottom_navigation",
           })
         }

Suggested companion test:

test("target message anchor survives a nearBottom scroll sample", () => {
  const { controller } = makeController()
  controller.intent({ type: "target_message", messageID: "msg_target", align: "nearest" })
  controller.observe({
    type: "scroll_sample",
    metrics: bottomMetrics,
    safePosition: { kind: "latest", messageID: "msg_latest" },
  })
  expect(controller.state().mode).toBe("targeting_message")
  expect(controller.state().lastSafePosition.kind).toBe("target_message")
})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (observation.type === "scroll_sample") {
if (observation.metrics.nearBottom) {
updateObservedSafePosition(state, observation.safePosition ?? { kind: "latest" })
if (state.lastIntent && isExplicitBottomIntent(state.lastIntent)) {
state.lastSafePosition = observation.safePosition?.kind === "latest" ? observation.safePosition : { kind: "latest" }
if (state.mode !== "targeting_message") {
state.mode = "following_latest"
state.latestProtected = true
return result({
before,
observation,
accepted: true,
recovery: noRecovery,
reason: "explicit_bottom_navigation",
})
state.latestProtected = false
}
return result({
before,
observation,
accepted: true,
recovery: noRecovery,
reason: "explicit_bottom_navigation",
})
}
if (observation.type === "scroll_sample") {
if (observation.metrics.nearBottom) {
if (state.mode !== "targeting_message") {
state.lastSafePosition =
observation.safePosition?.kind === "latest" ? observation.safePosition : { kind: "latest" }
state.mode = "following_latest"
state.latestProtected = false
}
return result({
before,
observation,
accepted: true,
recovery: noRecovery,
reason: "explicit_bottom_navigation",
})
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/app/src/pages/session/session-timeline-scroll-controller.ts` around
lines 519 - 533, The nearBottom branch of observe currently assigns
state.lastSafePosition to latest unconditionally, which can overwrite a
target_message anchor; update the nearBottom handling in observe so it does not
change state.lastSafePosition when the controller is currently targeting a
message whose lastSafePosition.kind === "target_message" (i.e., if state.mode
=== "targeting_message" && state.lastSafePosition?.kind === "target_message"
then skip updating lastSafePosition), but still perform the existing mode/
latestProtected updates and return the same result; this preserves the invariant
used by the target restoration logic and aligns with
updateObservedSafePosition's intent.

Astro-Han added 5 commits May 13, 2026 15:51
…e dirty tail

Streaming flushes were re-running `temp.innerHTML + decorate + morphdom` against
the full visible message every 24 ms — the trace's largest single contributor to
INP 200+ in PR #589's session view. The cache around `Markdown` already shared
parsed HTML between flushes, but every flush still tore down and rebuilt the
entire DOM tree, so cache hits saved a parse without saving the DOM work.

`stream()` now emits a separate stable head block and dirty tail block whenever
the lexer produced more than one top-level token, alongside the existing
unclosed-fence split. Reference-style links and single-token texts stay as one
dirty block — splitting them would risk orphaning inline tokens that resolve
against later definitions.

`<Markdown>` builds a wrapper child per block, keyed by
`cacheKey + html + copy/copied labels`. A wrapper whose fingerprint is
unchanged is left entirely alone — no innerHTML, no decorate, no morphdom — so
the dirty tail's growth no longer drags closed prose above it back through the
full pipeline. Labels live in the fingerprint so an i18n locale switch still
re-decorates already-rendered code blocks (W1 i18n gate). When abort or settle
collapses N dirty wrappers into a single full-mode block, trailing wrappers are
trimmed before write so no orphan blocks linger.

Tests:
- `markdown-stream.test.ts`: existing cases extended with `stable`; new
  multi-token boundary + half-list / half-link / half-fence edge cases + a
  stability invariant property (same head text → identical head block across
  streaming progressions).
- `markdown.test.ts`: new contract for `renderBlocksToContainer` covering
  wrapper identity preservation, abort consistency, stable-key-with-changed-html
  must update, labels-change must invalidate copy button decoration, and the
  copy-button-not-duplicated guarantee under stable skip.
- `message-part-markdown.test.ts` (new): pins `createPacedValue`'s lifecycle
  contract (settle / abort / non-prefix sync / settle-not-permanent). The
  existing helper already satisfies it; the spec is for regression coverage.

`packages/ui/package.json`'s `test` script gains `--conditions=browser` so Bun
resolves SolidJS through its browser entry — the SSR entry has no reactive
runtime and silently bypasses the lifecycle assertions. Scoped to this package
only; the root and other workspaces are unaffected.

Part of PR #589 C-min perf treatment (5 cuts): Cut 1 of 5.
…t round

While an agent round is actively streaming (`[data-running]` on the round
element), the visible body churns frame-by-frame as the dirty tail grows.
Stacking trow expand / collapse height transitions and the per-tool
Collapsible.Content height transition on top of those layout passes was
contributing to the main-thread budget pressure GPT-X Pass 2 traced — both
animate `height: auto`, which re-resolves layout on every frame as new
tokens arrive. Suppress them inside the streaming scope only; the user's
own expand / collapse interactions after streaming settles still get the
full animation.

For the same reason, every `<TextShimmer>` inside the streaming round's
`[data-slot="agent-body"]` falls back to a static base render — animation
off, char-shimmer faded, char-base forced visible. The shimmer in the
agent header (`[data-slot="agent-status"]`, outside the agent body) is
the one allowed running instance per the C-min plan's "shimmer 收敛到
agent header 单实例" boundary. The override mirrors the
`prefers-reduced-motion` block already in `text-shimmer.css`.

CSS only. The 5-minute visual checklist
(`docs/design/session-view-w1-manual-checklist.md`) covers verification.

Part of PR #589 C-min perf treatment (5 cuts): Cut 4 of 5.
`SessionSidePanel`'s effect calls `view().terminal.close()` whenever the
panel collapses or its active tab leaves "terminal", but the side panel's
own tab content gate (`<Show when={sidePanelTab() === "terminal"}>`)
checked only the active tab — not `open()`. The result, called out in
GPT-X's Pass 2 trace, is that ghostty-web's `startRenderLoop` kept rAF'ing
even after the user collapsed the panel: `<Terminal>` stayed mounted
because `TerminalPanel`'s own `<Show when={terminal.active()}>` is keyed
on the active terminal id, which doesn't clear on panel-driven view
closes. The trace recorded `startRenderLoop` running 4565 times for 598ms
of JS plus 245ms of style during a session that wasn't even surfacing the
terminal panel.

Gating `<Show>` on `opened() && terminal.active()` unmounts `<Terminal>`
through its existing cleanup / persist / websocket close path; reopening
remounts and reconnects normally. ghostty-web's internal render loop is
intentionally untouched — that lives behind the encapsulation boundary
and is a P1 follow-up if hidden-tab dirty-render becomes a real-world
cost again.

Verification: `bun run typecheck` clean across app + ui, focused
`bun test packages/app/src/pages` 511/511 pass, ui `bun run test` 502/3
unchanged (3 pre-existing W1 baseline drift). The visual side of the
behavior — `<Terminal>` actually unmounting on panel collapse — relies on
the 5-minute checklist's manual DevTools profile pass (Lead's brief
acknowledged spec is too costly to wire through the ghostty-web boundary).

Part of PR #589 C-min perf treatment (5 cuts): Cut 5 of 5.
@Astro-Han
Copy link
Copy Markdown
Owner Author

Closing in favor of #599. The 5-cut perf cluster (commits 969f6a9, f7646cb, 61e7a0f, aed3994, f49fd6d) regressed home INP from ~213-240ms to 199-3120ms on retest. Root cause and lessons captured in #599 body (postmortem section). Message-flow rewrite continues in #601 behind the perf gate from #600.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app Application behavior and product flows P2 Medium priority tech-debt Internal cleanup and maintainability debt ui Design system and user interface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant