Skip to content

Commit ae27fed

Browse files
committed
fix(app): preserve tool card outlines
1 parent d0bce4f commit ae27fed

2 files changed

Lines changed: 141 additions & 48 deletions

File tree

Lines changed: 139 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
import { expect, test } from "@playwright/test"
2-
import { assistantMessage, setupTimeline, shell, userMessage } from "../performance/timeline-stability/fixture"
1+
import { expect, test, type Locator, type Page } from "@playwright/test"
2+
import {
3+
assistantMessage,
4+
setupTimeline,
5+
shell,
6+
toolPart,
7+
userMessage,
8+
} from "../performance/timeline-stability/fixture"
39

410
for (const deviceScaleFactor of [1.25, 1.5]) {
511
test(`keeps the shell outline inside a fractionally short virtual row at ${deviceScaleFactor}x`, async ({ page }) => {
@@ -43,54 +49,141 @@ for (const deviceScaleFactor of [1.25, 1.5]) {
4349
})
4450
expect(clipped).toBeCloseTo(0.49, 1)
4551

46-
const box = await output.boundingBox()
47-
if (!box) throw new Error("Shell output bounds are unavailable")
4852
expect(await page.evaluate(() => devicePixelRatio)).toBe(deviceScaleFactor)
49-
const screenshot = await page.screenshot()
50-
const edges = await page.evaluate(
51-
async ({ source, size, viewport }) => {
52-
const image = new Image()
53-
image.src = source
54-
await image.decode()
55-
const canvas = document.createElement("canvas")
56-
canvas.width = image.naturalWidth
57-
canvas.height = image.naturalHeight
58-
const context = canvas.getContext("2d")
59-
if (!context) throw new Error("2D canvas is unavailable")
60-
context.drawImage(image, 0, 0)
61-
const scale = {
62-
x: image.naturalWidth / viewport.width,
63-
y: image.naturalHeight / viewport.height,
64-
}
65-
const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data
66-
const rows = new Uint32Array(image.naturalHeight)
67-
const columns = new Uint32Array(image.naturalWidth)
68-
for (let index = 0; index < pixels.length; index += 4) {
69-
if (pixels[index]! <= 200 || pixels[index + 1]! >= 50 || pixels[index + 2]! <= 200) continue
70-
const pixel = index / 4
71-
const x = pixel % image.naturalWidth
72-
const y = Math.floor(pixel / image.naturalWidth)
73-
rows[y] = rows[y]! + 1
74-
columns[x] = columns[x]! + 1
75-
}
76-
return {
77-
horizontal: Array.from(rows).filter((count) => count > size.width * scale.x * 0.75).length,
78-
vertical: Array.from(columns).filter((count) => count > size.height * scale.y * 0.75).length,
79-
}
80-
},
81-
{
82-
source: `data:image/png;base64,${screenshot.toString("base64")}`,
83-
viewport: page.viewportSize()!,
84-
size: { width: box.width, height: box.height },
85-
},
86-
)
53+
const edges = await captureCardEdges(page, output)
8754

88-
expect(box.width).toBeCloseTo(geometry.outputWidth, 2)
89-
expect(box.height).toBeCloseTo(geometry.outputHeight, 2)
55+
expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2)
56+
expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2)
9057
expect(geometry.borderColor).toBe("rgb(255, 0, 255)")
9158
expect(geometry.boxShadow).toBe("none")
9259
expect(geometry.clipMargin).toBe("0.5px")
93-
expect(edges.horizontal).toBeGreaterThanOrEqual(2)
94-
expect(edges.vertical).toBeGreaterThanOrEqual(2)
60+
expect(edges.magenta.top).toBeGreaterThan(0.75)
61+
expect(edges.magenta.bottom).toBeGreaterThan(0.75)
62+
expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2)
9563
})
9664
}
65+
66+
test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => {
67+
const patchID = "prt_patch_outline"
68+
const file = {
69+
filePath: "src/outline.ts",
70+
relativePath: "src/outline.ts",
71+
type: "update",
72+
additions: 1,
73+
deletions: 1,
74+
before: "const outline = false\n",
75+
after: "const outline = true\n",
76+
}
77+
const timeline = await setupTimeline(page, {
78+
messages: [
79+
userMessage(),
80+
assistantMessage([
81+
toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }),
82+
]),
83+
],
84+
settings: { editToolPartsExpanded: true, newLayoutDesigns: true },
85+
reducedMotion: true,
86+
})
87+
const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
88+
const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
89+
const row = page.locator("[data-timeline-key]", { has: part })
90+
await expect(card).toBeVisible()
91+
await timeline.settle()
92+
93+
const geometry = await row.evaluate((element) => {
94+
const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
95+
if (!card) throw new Error("Patch card is unavailable")
96+
const rowRect = element.getBoundingClientRect()
97+
const cardRect = card.getBoundingClientRect()
98+
element.style.height = `${cardRect.bottom - rowRect.top - 0.49}px`
99+
const clipMargin = getComputedStyle(element).overflowClipMargin
100+
const bottom = element.getBoundingClientRect().bottom
101+
return {
102+
overflow: card.getBoundingClientRect().bottom - bottom,
103+
paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin),
104+
clipMargin,
105+
cardWidth: cardRect.width,
106+
cardHeight: cardRect.height,
107+
}
108+
})
109+
await timeline.settle()
110+
111+
expect(geometry.overflow).toBeCloseTo(0.49, 1)
112+
expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
113+
const edges = await captureCardEdges(page, card)
114+
expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2)
115+
expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2)
116+
expect(edges.luminance.top).toBeLessThan(245)
117+
expect(edges.luminance.bottom).toBeLessThan(245)
118+
expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10)
119+
expect(geometry.clipMargin).toBe("0.5px")
120+
})
121+
122+
async function captureCardEdges(page: Page, card: Locator) {
123+
const box = await card.boundingBox()
124+
if (!box) throw new Error("Tool card bounds are unavailable")
125+
const viewport = page.viewportSize()
126+
if (!viewport) throw new Error("Viewport bounds are unavailable")
127+
const screenshot = await page.screenshot()
128+
return page.evaluate(
129+
async ({ source, box, viewport }) => {
130+
const image = new Image()
131+
image.src = source
132+
await image.decode()
133+
const canvas = document.createElement("canvas")
134+
canvas.width = image.naturalWidth
135+
canvas.height = image.naturalHeight
136+
const context = canvas.getContext("2d")
137+
if (!context) throw new Error("2D canvas is unavailable")
138+
context.drawImage(image, 0, 0)
139+
const scale = {
140+
x: image.naturalWidth / viewport.width,
141+
y: image.naturalHeight / viewport.height,
142+
}
143+
const rows = (candidates: number[]) => {
144+
const left = Math.floor((box.x + 8) * scale.x)
145+
const width = Math.floor((box.width - 16) * scale.x)
146+
return candidates.map((row) => {
147+
const pixels = context.getImageData(left, row, width, 1).data
148+
const indexes = Array.from({ length: width }, (_, index) => index * 4)
149+
return {
150+
luminance:
151+
indexes
152+
.map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3)
153+
.reduce((sum, value) => sum + value, 0) / width,
154+
magenta:
155+
indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200)
156+
.length / width,
157+
}
158+
})
159+
}
160+
const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data
161+
const columns = new Uint32Array(image.naturalWidth)
162+
for (let index = 0; index < pixels.length; index += 4) {
163+
if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue
164+
columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1
165+
}
166+
const top = box.y * scale.y
167+
const bottom = (box.y + box.height) * scale.y
168+
const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)])
169+
const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1])
170+
return {
171+
box,
172+
luminance: {
173+
top: Math.min(...topRows.map((row) => row.luminance)),
174+
bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance,
175+
},
176+
magenta: {
177+
top: Math.max(...topRows.map((row) => row.magenta)),
178+
bottom: Math.max(...bottomRows.map((row) => row.magenta)),
179+
vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length,
180+
},
181+
}
182+
},
183+
{
184+
source: `data:image/png;base64,${screenshot.toString("base64")}`,
185+
viewport,
186+
box,
187+
},
188+
)
189+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1279,8 +1279,8 @@ export function MessageTimeline(props: {
12791279
width: "100%",
12801280
height: `${item().size}px`,
12811281
overflow: "clip",
1282-
// Rounded virtual measurements can otherwise clip the shell card's outer border pixel.
1283-
"overflow-clip-margin": tool()?.tool === "bash" ? "0.5px" : undefined,
1282+
// Rounded virtual measurements can otherwise clip a tool card's outer border pixel.
1283+
"overflow-clip-margin": tool() ? "0.5px" : undefined,
12841284
}}
12851285
>
12861286
<div

0 commit comments

Comments
 (0)