Skip to content

Commit bf6ff7d

Browse files
committed
fix(app): remeasure timeline context expansion
1 parent e66084f commit bf6ff7d

6 files changed

Lines changed: 361 additions & 4 deletions

File tree

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@
138138
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
139139
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
140140
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
141-
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
141+
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
142+
"virtua@0.49.1": "patches/virtua@0.49.1.patch"
142143
}
143144
}
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
import { expect, test, type Page, type Route } from "@playwright/test"
2+
3+
const directory = "C:/OpenCode/ContextResizeRegression"
4+
const projectID = "proj_context_resize_regression"
5+
const sessionID = "ses_context_resize_regression"
6+
const title = "Context resize regression"
7+
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
8+
const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"]
9+
const followingTextID = "prt_0104_text"
10+
11+
type Message = { info: Record<string, unknown> & { id: string; role: "user" | "assistant" }; parts: Record<string, unknown>[] }
12+
13+
const messages = [
14+
...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(),
15+
...turn(10, true),
16+
]
17+
18+
test.describe("regression: session timeline context group resize", () => {
19+
test("remeasures a recent explored context group before the next paint", async ({ page }) => {
20+
await page.setViewportSize({ width: 1400, height: 900 })
21+
await mockServer(page)
22+
await configurePage(page)
23+
24+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
25+
await expect(page.getByRole("heading", { name: title })).toBeVisible()
26+
await expect(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()).toBeVisible()
27+
await expect(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first()).toBeVisible()
28+
await settle(page)
29+
30+
const samples = await sampleExpansion(page)
31+
const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5)
32+
33+
console.log("context resize samples", JSON.stringify(samples, null, 2))
34+
35+
expect(samples[0]?.overlap).toBe(0)
36+
expect(visibleOverlap).toEqual([])
37+
expect(samples.at(-1)?.expanded).toBe("true")
38+
})
39+
})
40+
41+
async function configurePage(page: Page) {
42+
await page.addInitScript(() => {
43+
localStorage.setItem(
44+
"settings.v3",
45+
JSON.stringify({
46+
general: {
47+
editToolPartsExpanded: true,
48+
shellToolPartsExpanded: true,
49+
showReasoningSummaries: true,
50+
showSessionProgressBar: true,
51+
},
52+
}),
53+
)
54+
})
55+
}
56+
57+
async function sampleExpansion(page: Page) {
58+
return page.evaluate(
59+
({ contextIDs, followingTextID }) =>
60+
new Promise<
61+
{
62+
frame: number
63+
label: string
64+
scrollTop: number
65+
scrollHeight: number
66+
contextBottom: number
67+
textTop: number
68+
overlap: number
69+
gap: number
70+
expanded: string | null
71+
}[]
72+
>((resolve) => {
73+
const context = document.querySelector<HTMLElement>(`[data-timeline-part-ids="${contextIDs.join(",")}"]`)
74+
const text = document.querySelector<HTMLElement>(`[data-timeline-part-id="${followingTextID}"]`)
75+
const scroller = context?.closest<HTMLElement>(".scroll-view__viewport")
76+
const trigger = context?.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')
77+
const contextRow = context?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
78+
const textRow = text?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
79+
if (!context || !text || !scroller || !trigger || !contextRow || !textRow) throw new Error("missing regression nodes")
80+
81+
scroller.scrollTop = scroller.scrollHeight
82+
const samples: {
83+
frame: number
84+
label: string
85+
scrollTop: number
86+
scrollHeight: number
87+
contextBottom: number
88+
textTop: number
89+
overlap: number
90+
gap: number
91+
expanded: string | null
92+
}[] = []
93+
const capture = (frame: number, label: string) => {
94+
const contextRect = contextRow.getBoundingClientRect()
95+
const textRect = textRow.getBoundingClientRect()
96+
samples.push({
97+
frame,
98+
label,
99+
scrollTop: Math.round(scroller.scrollTop * 10) / 10,
100+
scrollHeight: Math.round(scroller.scrollHeight * 10) / 10,
101+
contextBottom: Math.round(contextRect.bottom * 10) / 10,
102+
textTop: Math.round(textRect.top * 10) / 10,
103+
overlap: Math.max(0, Math.round((contextRect.bottom - textRect.top) * 10) / 10),
104+
gap: Math.max(0, Math.round((textRect.top - contextRect.bottom) * 10) / 10),
105+
expanded: trigger.getAttribute("aria-expanded"),
106+
})
107+
}
108+
109+
capture(-1, "before")
110+
trigger.click()
111+
capture(0, "sync-after-click")
112+
113+
let frame = 1
114+
const tick = () => {
115+
capture(frame, "raf")
116+
frame += 1
117+
if (frame > 8) {
118+
resolve(samples)
119+
return
120+
}
121+
requestAnimationFrame(tick)
122+
}
123+
requestAnimationFrame(tick)
124+
}),
125+
{ contextIDs, followingTextID },
126+
)
127+
}
128+
129+
function turn(index: number, target: boolean): Message[] {
130+
const userID = id("msg_user", index)
131+
const assistantID = id("msg_assistant", index)
132+
return [
133+
{
134+
info: {
135+
id: userID,
136+
sessionID,
137+
role: "user",
138+
time: { created: 1700000000000 + index * 10_000 },
139+
summary: { diffs: [] },
140+
agent: "build",
141+
model,
142+
},
143+
parts: [{ id: id("prt_user", index), sessionID, messageID: userID, type: "text", text: `User message ${index}` }],
144+
},
145+
{
146+
info: {
147+
id: assistantID,
148+
sessionID,
149+
role: "assistant",
150+
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 2_000 },
151+
parentID: userID,
152+
modelID: model.modelID,
153+
providerID: model.providerID,
154+
mode: "build",
155+
agent: "build",
156+
path: { cwd: directory, root: directory },
157+
cost: 0.01,
158+
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
159+
variant: "max",
160+
finish: "stop",
161+
},
162+
parts: target
163+
? [
164+
contextTool(contextIDs[0]!, assistantID, "read", { filePath: "src/recent-a.ts", offset: 0, limit: 120 }),
165+
contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }),
166+
contextTool(contextIDs[2]!, assistantID, "grep", { path: directory, pattern: "Explored", include: "*.ts" }),
167+
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }),
168+
{
169+
id: followingTextID,
170+
sessionID,
171+
messageID: assistantID,
172+
type: "text",
173+
text: "This assistant text is immediately after the explored context group.",
174+
},
175+
]
176+
: [
177+
{
178+
id: id("prt_text", index),
179+
sessionID,
180+
messageID: assistantID,
181+
type: "text",
182+
text: `Assistant filler ${index}. ${"filler ".repeat(60)}`,
183+
},
184+
],
185+
},
186+
]
187+
}
188+
189+
function contextTool(partID: string, messageID: string, tool: string, input: Record<string, unknown>) {
190+
return {
191+
id: partID,
192+
sessionID,
193+
messageID,
194+
type: "tool",
195+
callID: `call_${partID}`,
196+
tool,
197+
state: {
198+
status: "completed",
199+
input,
200+
output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`,
201+
title: input.filePath || input.path || input.pattern || "completed",
202+
metadata: {},
203+
time: { start: 1700000000000, end: 1700000000100 },
204+
},
205+
}
206+
}
207+
208+
async function mockServer(page: Page) {
209+
await page.route("**/*", async (route) => {
210+
const url = new URL(route.request().url())
211+
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
212+
if (url.port !== targetPort) return route.fallback()
213+
214+
const path = url.pathname
215+
if (path === "/global/event" || path === "/event") return sse(route)
216+
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(path)) return json(route, {})
217+
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/status", "/vcs/diff"].includes(path)) return json(route, [])
218+
if (path === "/provider") return json(route, provider())
219+
if (path === "/path") return json(route, { state: directory, config: directory, worktree: directory, directory, home: "C:/OpenCode" })
220+
if (path === "/project") return json(route, [project()])
221+
if (path === "/project/current") return json(route, project())
222+
if (path === "/agent") return json(route, [{ name: "build", mode: "primary" }])
223+
if (path === "/vcs") return json(route, { branch: "main", default_branch: "main" })
224+
if (path === "/session") return json(route, [session()])
225+
if (path === `/session/${sessionID}`) return json(route, session())
226+
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
227+
if (path === `/session/${sessionID}/message`) return json(route, messages)
228+
return json(route, {})
229+
})
230+
}
231+
232+
async function settle(page: Page) {
233+
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))
234+
}
235+
236+
function id(prefix: string, index: number) {
237+
return `${prefix}_${String(index).padStart(4, "0")}`
238+
}
239+
240+
function project() {
241+
return { id: projectID, worktree: directory, vcs: "git", name: "context-resize-regression", time: { created: 1700000000000, updated: 1700000000000 }, sandboxes: [] }
242+
}
243+
244+
function session() {
245+
return { id: sessionID, slug: "context-resize-regression", projectID, directory, title, version: "dev", time: { created: 1700000000000, updated: 1700000000000 } }
246+
}
247+
248+
function provider() {
249+
return {
250+
all: [
251+
{
252+
id: "opencode",
253+
name: "OpenCode",
254+
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
255+
},
256+
],
257+
connected: ["opencode"],
258+
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
259+
}
260+
}
261+
262+
function json(route: Route, body: unknown, headers?: Record<string, string>) {
263+
return route.fulfill({
264+
status: 200,
265+
contentType: "application/json",
266+
headers: { "access-control-allow-origin": "*", "access-control-expose-headers": "x-next-cursor", ...headers },
267+
body: JSON.stringify(body ?? null),
268+
})
269+
}
270+
271+
function sse(route: Route) {
272+
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
273+
}
274+
275+
function base64Encode(value: string) {
276+
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
277+
}

packages/app/src/pages/session/message-timeline.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,11 @@ export function MessageTimeline(props: {
573573

574574
const isMeasuredBottom = (root: HTMLDivElement) => root.scrollHeight - root.clientHeight - root.scrollTop <= 4
575575

576+
const measureTimeline = () => {
577+
virtualizer?.measure()
578+
anchorMeasuredBottom()
579+
}
580+
576581
function anchorMeasuredBottom() {
577582
if (!listRoot) return false
578583
if (!measuredBottomAnchored) return false
@@ -1003,7 +1008,13 @@ export function MessageTimeline(props: {
10031008
.filter((part): part is ToolPart => part?.type === "tool")
10041009
})
10051010

1006-
return <ContextToolGroup parts={parts()} busy={workingTurn(row().userMessageID) && row().lastAssistantPart} />
1011+
return (
1012+
<ContextToolGroup
1013+
parts={parts()}
1014+
busy={workingTurn(row().userMessageID) && row().lastAssistantPart}
1015+
onSizeChange={measureTimeline}
1016+
/>
1017+
)
10071018
}
10081019

10091020
const message = createMemo(() => {

packages/ui/src/components/message-part.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -931,19 +931,23 @@ export function AssistantMessageDisplay(props: {
931931
)
932932
}
933933

934-
export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
934+
export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSizeChange?: () => void }) {
935935
const i18n = useI18n()
936936
const [open, setOpen] = createSignal(false)
937937
const pending = createMemo(
938938
() =>
939939
!!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
940940
)
941941
const summary = createMemo(() => contextToolSummary(props.parts))
942+
const handleOpenChange = (value: boolean) => {
943+
setOpen(value)
944+
props.onSizeChange?.()
945+
}
942946

943947
return (
944948
<Collapsible
945949
open={open()}
946-
onOpenChange={setOpen}
950+
onOpenChange={handleOpenChange}
947951
variant="ghost"
948952
class="tool-collapsible"
949953
data-timeline-part-ids={props.parts.map((part) => part.id).join(",")}

0 commit comments

Comments
 (0)