-
Notifications
You must be signed in to change notification settings - Fork 2
refactor(session): rewrite session view per W1 visual lock for slice 11b.1 #589
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from 2 commits
Commits
Show all changes
60 commits
Select commit
Hold shift + click to select a range
1cf9f6a
refactor(ui): introduce groupParts pure helper for session-turn
Astro-Han bb381ed
refactor(ui): introduce TrowBlock reducer for session-turn
Astro-Han a70e960
style(ui): align shimmer duration to W1 lock (1200→1800ms)
Astro-Han 53a4cea
feat(ui): register fork icon placeholder for session agent toolbar
Astro-Han 722d9fa
feat(ui): add shared AttachmentChip primitive
Astro-Han 3911332
feat(ui): add session-turn user bubble for slice 11b.1
Astro-Han e421774
feat(ui): add session-turn system event for slice 11b.1
Astro-Han 55d77ae
feat(ui): add JumpToBottom floating button for slice 11b.1
Astro-Han 421841a
feat(ui): add session-turn agent round for slice 11b.1
Astro-Han b5a995c
feat(ui): fill TrowBlock render shell for slice 11b.1
Astro-Han 9594e0f
docs(ui): annotate Phase 5 token deferrals for slice 11b.1
Astro-Han 548fa26
feat(ui): add SessionTurnV2 hybrid opt-in shell for slice 11b.1
Astro-Han de6a321
fix(ui): make TrowBlock body reactive across streaming tool updates
Astro-Han 0fbee29
revert(ui): drop SessionTurnV2 hybrid opt-in shell for slice 11b.1
Astro-Han 20ccbb1
feat(ui): register --fg-secondary and --icon-chev tokens for slice 11b.1
Astro-Han e335a1a
refactor(ui): extract message-part types to dedicated module
Astro-Han 892ef1a
refactor(ui): extract message-part registry to dedicated module
Astro-Han 5401c26
refactor(ui): extract paced markdown helpers to dedicated module
Astro-Han 610e2db
refactor(ui): extract tool-info helpers to dedicated module
Astro-Han cad41ef
refactor(ui): extract legacy assistant grouping helpers
Astro-Han c3518eb
refactor(ui): extract AssistantMessageDisplay + AssistantParts + Cont…
Astro-Han eef95b8
refactor(ui): extract UserMessageDisplay to dedicated module
Astro-Han 1c01839
refactor(ui): extract tool dispatcher + display chrome to dedicated m…
Astro-Han 78e9506
refactor(ui): extract MessageDivider + text/reasoning/compaction rend…
Astro-Han 36100de
refactor(ui): extract lightweight tool renderers to tools-basic module
Astro-Han d44e0e4
refactor(ui): extract task/agent tool renderer to tools-agent module
Astro-Han 1b43dac
refactor(ui): extract bash tool renderer to tools-shell module
Astro-Han e82bf8f
refactor(ui): extract file-modifying tools + slim message-part aggreg…
Astro-Han c87cb47
refactor(app): extract 5 helper modules from message-timeline
Astro-Han e4b21bd
refactor(app): finish message-timeline split + drop dead title editor
Astro-Han ade42fe
refactor(ui): extract turn-changes + diffs lists + helpers from sessi…
Astro-Han d81e0bf
feat(ui-i18n): add slice 11b.1 W1 surface keys
Astro-Han 9a1ec65
feat(app/session): mount JumpToBottom leaf in message-timeline
Astro-Han 116d542
feat(app/session): wire fork action for W1 agent toolbar
Astro-Han cb90f6b
feat(session-turn): mount W1 leaf components on the default user path
Astro-Han e177559
test(e2e/session): add W1 surface specs covering E1-E16 golden paths
Astro-Han 77f8e8c
fix(session-turn): align W1 bubble + agent toolbar per AstroHan W1 re…
Astro-Han 4457e73
fix(auto-scroll): hold ResizeObserver auto-snap after upward wheel
Astro-Han add81e2
fix(session-turn): wrap W1 toolbar buttons in Tooltip for hover hints
Astro-Han c5a030f
fix(session-turn): selection regression + trow chev direction + neste…
Astro-Han a4b44e2
fix(session-turn): lift dark user bubble fill so the shell stays visible
Astro-Han 6b62f8b
Revert "fix(session-turn): lift dark user bubble fill so the shell st…
Astro-Han d8addf2
fix(session-turn): give dark user bubble a hairline ring for shape
Astro-Han e2e5def
fix(session-timeline): close P0 #6 scroll-up-snaps-back via 3 minimal…
Astro-Han 90e3903
fix(session-turn): scope L417 frame + caption typography to per-tool …
Astro-Han 186d5f8
fix(session-turn): give light user bubble a hairline ring too
Astro-Han 55893a9
fix(session-turn): trow visual fixes — drop per-tool frame, fix chev
Astro-Han b48a860
fix(session-turn): tighten "Thinking…" to pre-first-visible-output only
Astro-Han 8ac986b
test(app): tag W1 regression smoke gate
Astro-Han 8ef83fa
fix(session-turn): trow expand/collapse animation + tighter spacing +…
Astro-Han 83d1dc7
fix(session-turn): wire showReasoningSummaries to AgentRound + trunca…
Astro-Han 9943552
fix(session-turn): drop W1-broken shell/edit tool defaultOpen toggles
Astro-Han b2ee6cb
fix(session-timeline): surface trow toggle as a layout_interaction in…
Astro-Han 47a4760
fix(session): unify timeline scroll ownership
Astro-Han 9e071d2
style(session-turn): tighten trow result body row stride
Astro-Han 969f6a9
perf(ui): render streaming markdown per-block so flushes only diff th…
Astro-Han f7646cb
perf(app): coalesce part deltas across status events
Astro-Han 61e7a0f
perf(app): throttle message part delta flushes
Astro-Han aed3994
perf(ui): suppress streaming-period animations inside the active agen…
Astro-Han f49fd6d
perf(app): unmount the active terminal when the side panel hides it
Astro-Han File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,219 @@ | ||
| import { expect, test, describe } from "bun:test" | ||
| import type { Part, ReasoningPart, TextPart, ToolPart } from "@opencode-ai/sdk/v2" | ||
| import { groupParts, type PartGroup } from "./message-part-group" | ||
|
|
||
| // Minimal factories. Pure-function tests only read a subset of fields | ||
| // (type, id, text, tool name, state.status). Other SDK fields are filled | ||
| // with stub values so the structural literal type-checks under the SDK union. | ||
|
|
||
| function textPart(id: string, text: string): TextPart { | ||
| return { | ||
| id, | ||
| sessionID: "s", | ||
| messageID: "m", | ||
| type: "text", | ||
| text, | ||
| } | ||
| } | ||
|
|
||
| function reasoningPart(id: string, text: string): ReasoningPart { | ||
| return { | ||
| id, | ||
| sessionID: "s", | ||
| messageID: "m", | ||
| type: "reasoning", | ||
| text, | ||
| time: { start: 0 }, | ||
| } | ||
| } | ||
|
|
||
| function toolPart(id: string, tool: string, status: "pending" | "running" | "completed" | "error" = "completed"): ToolPart { | ||
| // ToolState is a discriminated union — branch on status to satisfy each variant's shape. | ||
| if (status === "pending") { | ||
| return { | ||
| id, | ||
| sessionID: "s", | ||
| messageID: "m", | ||
| type: "tool", | ||
| callID: `call-${id}`, | ||
| tool, | ||
| state: { status: "pending", input: {}, raw: "" }, | ||
| } | ||
| } | ||
| if (status === "running") { | ||
| return { | ||
| id, | ||
| sessionID: "s", | ||
| messageID: "m", | ||
| type: "tool", | ||
| callID: `call-${id}`, | ||
| tool, | ||
| state: { status: "running", input: {}, time: { start: 0 } }, | ||
| } | ||
| } | ||
| if (status === "error") { | ||
| return { | ||
| id, | ||
| sessionID: "s", | ||
| messageID: "m", | ||
| type: "tool", | ||
| callID: `call-${id}`, | ||
| tool, | ||
| state: { status: "error", input: {}, error: "fail", time: { start: 0, end: 1 } }, | ||
| } | ||
| } | ||
| return { | ||
| id, | ||
| sessionID: "s", | ||
| messageID: "m", | ||
| type: "tool", | ||
| callID: `call-${id}`, | ||
| tool, | ||
| state: { status: "completed", input: {}, output: "", title: "", metadata: {}, time: { start: 0, end: 1 } }, | ||
| } | ||
| } | ||
|
|
||
| // "Unknown" SDK part types still typecheck against `Part` since the union | ||
| // already includes step-start, snapshot, agent, retry, etc. — we use one | ||
| // here as the unknown-to-the-grouper carrier. | ||
| function stepStartPart(id: string): Part { | ||
| return { | ||
| id, | ||
| sessionID: "s", | ||
| messageID: "m", | ||
| type: "step-start", | ||
| } | ||
| } | ||
|
|
||
| describe("groupParts", () => { | ||
| test("empty input returns empty array", () => { | ||
| expect(groupParts([])).toEqual([]) | ||
| }) | ||
|
|
||
| test("text-only emits prose groups in order, no trow-block", () => { | ||
| const result = groupParts([textPart("t1", "hello"), textPart("t2", "world")]) | ||
| expect(result).toHaveLength(2) | ||
| expect(result[0]).toEqual({ kind: "prose", partID: "t1", text: "hello" }) | ||
| expect(result[1]).toEqual({ kind: "prose", partID: "t2", text: "world" }) | ||
| }) | ||
|
|
||
| test("tool-only emits a single trow-block holding every tool", () => { | ||
| const tools = [toolPart("a", "bash"), toolPart("b", "bash"), toolPart("c", "edit")] | ||
| const result = groupParts(tools) | ||
| expect(result).toHaveLength(1) | ||
| const group = result[0] as PartGroup | ||
| expect(group.kind).toBe("trow-block") | ||
| if (group.kind !== "trow-block") throw new Error("expected trow-block") | ||
| expect(group.parts.map((p) => p.id)).toEqual(["a", "b", "c"]) | ||
| }) | ||
|
|
||
| test("interleaved tool/text/tool produces trow-block, prose, trow-block", () => { | ||
| const result = groupParts([ | ||
| toolPart("t1", "bash"), | ||
| toolPart("t2", "bash"), | ||
| textPart("p1", "intermediate prose"), | ||
| toolPart("t3", "edit"), | ||
| ]) | ||
| expect(result).toHaveLength(3) | ||
| expect(result[0].kind).toBe("trow-block") | ||
| expect(result[1]).toEqual({ kind: "prose", partID: "p1", text: "intermediate prose" }) | ||
| expect(result[2].kind).toBe("trow-block") | ||
| }) | ||
|
|
||
| test("trailing tools after prose flush as their own trow-block (unflushed-tail guard)", () => { | ||
| const result = groupParts([ | ||
| textPart("p1", "first"), | ||
| toolPart("t1", "bash"), | ||
| toolPart("t2", "bash"), | ||
| ]) | ||
| expect(result).toHaveLength(2) | ||
| expect(result[0]).toEqual({ kind: "prose", partID: "p1", text: "first" }) | ||
| expect(result[1].kind).toBe("trow-block") | ||
| }) | ||
|
|
||
| test("reasoning splits a tool run the same way prose does (reasoning is a flush boundary)", () => { | ||
| const result = groupParts([ | ||
| toolPart("t1", "bash"), | ||
| reasoningPart("r1", "thinking..."), | ||
| toolPart("t2", "edit"), | ||
| ]) | ||
| expect(result).toHaveLength(3) | ||
| expect(result[0].kind).toBe("trow-block") | ||
| expect(result[1]).toEqual({ kind: "reasoning", partID: "r1", text: "thinking..." }) | ||
| expect(result[2].kind).toBe("trow-block") | ||
| }) | ||
|
|
||
| test("reasoning is a distinct kind from prose (so the renderer can pick different visuals)", () => { | ||
| const result = groupParts([reasoningPart("r1", "step")]) | ||
| expect(result).toHaveLength(1) | ||
| expect(result[0].kind).toBe("reasoning") | ||
| }) | ||
|
|
||
| test("hidden tool (todowrite) is filtered before grouping; it does not flush a pending tool run", () => { | ||
| // Two real tools straddling a todowrite — todowrite is filtered out via | ||
| // renderable(), so the two real tools remain consecutive and merge into | ||
| // one trow-block. | ||
| const result = groupParts([ | ||
| toolPart("t1", "bash"), | ||
| toolPart("h1", "todowrite"), | ||
| toolPart("t2", "bash"), | ||
| ]) | ||
| expect(result).toHaveLength(1) | ||
| if (result[0].kind !== "trow-block") throw new Error("expected single trow-block") | ||
| expect(result[0].parts.map((p) => p.id)).toEqual(["t1", "t2"]) | ||
| }) | ||
|
|
||
| test("empty text is dropped (renderable() rejects whitespace-only text)", () => { | ||
| const result = groupParts([ | ||
| toolPart("t1", "bash"), | ||
| textPart("p1", " "), | ||
| toolPart("t2", "bash"), | ||
| ]) | ||
| // The empty text is not renderable → does not flush. The two tools merge. | ||
| expect(result).toHaveLength(1) | ||
| expect(result[0].kind).toBe("trow-block") | ||
| }) | ||
|
|
||
| test("question tool while pending/running is filtered (not rendered, not grouped)", () => { | ||
| const result = groupParts([ | ||
| toolPart("t1", "bash"), | ||
| toolPart("q1", "question", "pending"), | ||
| toolPart("t2", "bash"), | ||
| ]) | ||
| expect(result).toHaveLength(1) | ||
| if (result[0].kind !== "trow-block") throw new Error("expected single trow-block") | ||
| expect(result[0].parts.map((p) => p.id)).toEqual(["t1", "t2"]) | ||
| }) | ||
|
|
||
| test("unknown / structurally-known-but-non-handled part neither flushes pending tools nor emits a group", () => { | ||
| // step-start is in the SDK union but is not one of the three kinds the | ||
| // grouper handles. Two adjacent tools across a step-start must stay in | ||
| // the same trow-block; the step-start must not appear in output. | ||
| const result = groupParts([ | ||
| toolPart("t1", "bash"), | ||
| stepStartPart("s1"), | ||
| toolPart("t2", "bash"), | ||
| ]) | ||
| expect(result).toHaveLength(1) | ||
| if (result[0].kind !== "trow-block") throw new Error("expected single trow-block") | ||
| expect(result[0].parts.map((p) => p.id)).toEqual(["t1", "t2"]) | ||
| }) | ||
|
|
||
| test("preserves part order across multiple flushes", () => { | ||
| const result = groupParts([ | ||
| textPart("p1", "a"), | ||
| toolPart("t1", "bash"), | ||
| textPart("p2", "b"), | ||
| toolPart("t2", "edit"), | ||
| toolPart("t3", "edit"), | ||
| textPart("p3", "c"), | ||
| ]) | ||
| expect(result.map((g) => g.kind)).toEqual([ | ||
| "prose", | ||
| "trow-block", | ||
| "prose", | ||
| "trow-block", | ||
| "prose", | ||
| ]) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| import type { Part, ReasoningPart, TextPart, ToolPart } from "@opencode-ai/sdk/v2" | ||
|
|
||
| /** | ||
| * Grouped render unit emitted by {@link groupParts}. | ||
| * | ||
| * - `prose` and `reasoning` carry a single text/reasoning part, kept as separate | ||
| * kinds so the renderer can pick distinct visuals (markdown body vs the | ||
| * `mf-reasoning` italic + `--fg-secondary` per DESIGN.md L482) while still | ||
| * sharing the same "flush boundary" role inside the grouping algorithm. | ||
| * - `trow-block` carries one or more consecutive tool parts that share a | ||
| * logical operation — the boundary is implicit prose / reasoning between | ||
| * tool runs (DESIGN.md L466). | ||
| */ | ||
| export type PartGroup = | ||
| | { kind: "prose"; partID: string; text: string } | ||
| | { kind: "reasoning"; partID: string; text: string } | ||
| | { kind: "trow-block"; parts: ToolPart[] } | ||
|
|
||
| const HIDDEN_TOOLS = new Set(["todowrite"]) | ||
|
|
||
| /** | ||
| * Pure, deterministic filter mirroring `message-part.tsx#renderable()` for the | ||
| * three part kinds the grouper handles (tool / text / reasoning). Other SDK | ||
| * part types (file, snapshot, step-start, agent, retry, …) intentionally | ||
| * return `false` here — see §6.20: the grouper skips them silently rather | ||
| * than emitting a prose-fallback or flushing the pending trow. | ||
| */ | ||
| function renderableForGrouping(part: Part): boolean { | ||
| if (part.type === "tool") { | ||
| if (HIDDEN_TOOLS.has(part.tool)) return false | ||
| if (part.tool === "question") { | ||
| return part.state.status !== "pending" && part.state.status !== "running" | ||
| } | ||
| return true | ||
| } | ||
| if (part.type === "text") return !!part.text?.trim() | ||
| if (part.type === "reasoning") return !!part.text?.trim() | ||
| return false | ||
| } | ||
|
|
||
| /** | ||
| * Group an assistant message's parts into ordered render units. | ||
| * | ||
| * Algorithm (DESIGN.md L466, slice 11b.1 §3.1): | ||
| * | ||
| * 1. Iterate `parts` in order, skipping any part for which | ||
| * {@link renderableForGrouping} returns `false`. | ||
| * 2. Consecutive renderable `tool` parts accumulate in a pending buffer. | ||
| * 3. A renderable `text` or `reasoning` part **flushes** the buffer | ||
| * (emitting one `trow-block` if non-empty) and then emits its own | ||
| * `prose` / `reasoning` group. Prose interleaving IS the | ||
| * "logical operation" boundary in DESIGN.md L466. | ||
| * 4. Any other part type is skipped silently — it does **not** flush | ||
| * the buffer and does **not** emit a group, so adjacent tool runs | ||
| * across an unknown / future part stay in the same trow-block. | ||
| * 5. After the walk, any remaining buffered tools are flushed. | ||
| * | ||
| * The function is structural, deterministic, and free of timing / | ||
| * callID heuristics so it can be unit-tested as a pure function. | ||
| */ | ||
| export function groupParts(parts: readonly Part[]): PartGroup[] { | ||
| const groups: PartGroup[] = [] | ||
| let pendingTools: ToolPart[] = [] | ||
|
|
||
| const flushTools = () => { | ||
| if (pendingTools.length > 0) { | ||
| groups.push({ kind: "trow-block", parts: pendingTools }) | ||
| pendingTools = [] | ||
| } | ||
| } | ||
|
|
||
| for (const part of parts) { | ||
| if (!renderableForGrouping(part)) { | ||
| // Unknown / hidden / empty text — skip silently (see §6.20). | ||
| // Important: do NOT flush, otherwise an interleaved hidden part | ||
| // (e.g. todowrite) would visually split a logical tool run. | ||
| continue | ||
| } | ||
| 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 | ||
| } | ||
| // Defensive: renderableForGrouping should have already rejected | ||
| // anything we cannot route. If a new type slips through it is a | ||
| // bug in renderableForGrouping — fall through silently. | ||
| } | ||
|
|
||
| flushTools() | ||
| return groups | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The explicit type casts to
ToolPart,TextPart, andReasoningPartare unnecessary. TypeScript's control flow analysis automatically narrows the type ofpartwithin theif (part.type === ...)blocks. Removing these casts makes the code cleaner and allows the type system to catch potential errors, improving maintainability.