Skip to content

Commit ba0ebbf

Browse files
committed
Merge branch 'opencode-mirror' into fork/local
2 parents 6738fa4 + e9f5d34 commit ba0ebbf

91 files changed

Lines changed: 29833 additions & 2276 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.zh.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,4 +125,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
125125

126126
---
127127

128-
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
128+
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=52ao9352-5623-4fa0-b7dd-3407c392c1af&qr_code=true) | [X.com](https://x.com/opencode)

README.zht.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,4 +125,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
125125

126126
---
127127

128-
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
128+
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=52ao9352-5623-4fa0-b7dd-3407c392c1af&qr_code=true) | [X.com](https://x.com/opencode)

packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,40 @@ test("does not reverse visible rows when the user wheels during shell remeasurem
4848
await reportVisualStability(testInfo, "wheel-during-resize", trace, rowPairPlan(regions, 1))
4949
})
5050

51+
test("keeps moving upward while drag-selecting above the timeline", async ({ page }) => {
52+
await setupTimeline(page, {
53+
messages: history(80),
54+
viewport: { width: 1400, height: 700 },
55+
reducedMotion: true,
56+
})
57+
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
58+
const text = page.getByText("History 79.", { exact: false })
59+
await expect(text).toBeVisible()
60+
await scroller.evaluate((element) => {
61+
element.dataset.selectionLength = "0"
62+
document.addEventListener("selectionchange", () => {
63+
element.dataset.selectionLength = String(
64+
Math.max(Number(element.dataset.selectionLength), window.getSelection()?.toString().length ?? 0),
65+
)
66+
})
67+
})
68+
const textBox = await text.boundingBox()
69+
const scrollBox = await scroller.boundingBox()
70+
expect(textBox).not.toBeNull()
71+
expect(scrollBox).not.toBeNull()
72+
if (!textBox || !scrollBox) return
73+
74+
await page.mouse.move(textBox.x + textBox.width - 10, textBox.y + textBox.height / 2)
75+
await page.mouse.down()
76+
await page.mouse.move(textBox.x + 20, scrollBox.y - 120, { steps: 30 })
77+
78+
await expect.poll(() => scroller.evaluate((element) => Number(element.dataset.selectionLength))).toBeGreaterThan(0)
79+
await expect
80+
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
81+
.toBeGreaterThan(500)
82+
await page.mouse.up()
83+
})
84+
5185
test("does not pull a keyboard-scrolled user during shell remeasurement", async ({ page }, testInfo) => {
5286
const shellID = "prt_keyboard_01_shell"
5387
const followingID = "prt_keyboard_02_following"
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import type { Page } from "@playwright/test"
2+
import { expectSessionTitle } from "../../utils/waits"
3+
import { mockOpenCodeServer } from "../../utils/mock-server"
4+
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
5+
import { fixture } from "./session-timeline-stress.fixture"
6+
import { installStressSessionTabs, stressSessionHref } from "./timeline-test-helpers"
7+
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
8+
9+
type ParentHydrationBenchmarkMode = "natural" | "candidate"
10+
11+
const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural"
12+
if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`)
13+
const userID = "msg_parent_hydration_user"
14+
const user = {
15+
...fixture.messages[fixture.targetID][0]!,
16+
info: { ...fixture.messages[fixture.targetID][0]!.info, id: userID, time: { created: 1700001000000 } },
17+
parts: fixture.messages[fixture.targetID][0]!.parts.map((part, index) => ({
18+
...part,
19+
id: `prt_parent_hydration_user_${index}`,
20+
messageID: userID,
21+
})),
22+
}
23+
const assistantSeed = fixture.messages[fixture.targetID][3]!
24+
const assistants = Array.from({ length: 14 }, (_, index) => {
25+
const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}`
26+
return {
27+
...assistantSeed,
28+
info: {
29+
...assistantSeed.info,
30+
id: messageID,
31+
parentID: userID,
32+
time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
33+
},
34+
parts: assistantSeed.parts.map((part, partIndex) => ({
35+
...part,
36+
id: `prt_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}`,
37+
messageID,
38+
})),
39+
}
40+
})
41+
const messages = [user, ...assistants]
42+
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
43+
const lastID = userID
44+
const lastPartID = assistants.at(-1)!.parts.at(-1)!.id
45+
46+
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
47+
benchmark.setTimeout(180_000)
48+
const results = [] as Awaited<ReturnType<typeof trial>>[]
49+
for (let run = 0; run < 5; run++) {
50+
results.push(
51+
await withBenchmarkPage(
52+
browser,
53+
`session-parent-hydration-${mode}-${run}`,
54+
(page) => trial(page, mode),
55+
testInfo,
56+
),
57+
)
58+
}
59+
const timing = results.map((result) => result.metrics.firstCorrectObservedMs!).sort((a, b) => a - b)
60+
report(
61+
{
62+
results: results.map((result) => ({ ...result.metrics, historyGateCount: result.historyGateCount })),
63+
summary: {
64+
firstCorrectObservedMs: { min: timing[0], median: timing[2], max: timing.at(-1) },
65+
blankSamples: results.map((result) => result.metrics.blankSamples),
66+
requestCounts: {
67+
list: results.map((result) => result.requestCounts.list),
68+
parent: results.map((result) => result.requestCounts.parent),
69+
},
70+
historyGateCount: results.map((result) => result.historyGateCount),
71+
},
72+
},
73+
{ mode },
74+
)
75+
})
76+
77+
async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
78+
const requests: { type: "list" | "parent"; before?: string }[] = []
79+
const history = mode === "candidate" ? Promise.withResolvers<void>() : undefined
80+
let historyGates = 0
81+
await mockOpenCodeServer(page, {
82+
sessions: fixture.sessions.filter((session) => session.id === fixture.sourceID),
83+
provider: fixture.provider,
84+
directory: fixture.directory,
85+
project: fixture.project,
86+
messageDelay: 50,
87+
onMessages: (request) => {
88+
if (request.sessionID === fixture.targetID && request.phase === "start")
89+
requests.push({ type: "list", before: request.before })
90+
},
91+
beforeMessagesResponse: (request) => {
92+
if (mode !== "candidate" || request.sessionID !== fixture.targetID || !request.before) return Promise.resolve()
93+
historyGates++
94+
return history!.promise
95+
},
96+
onMessage: (request) => {
97+
if (request.sessionID === fixture.targetID && request.messageID === userID) requests.push({ type: "parent" })
98+
},
99+
message: (sessionID, messageID) => {
100+
if (sessionID !== fixture.targetID || messageID !== userID) return
101+
return user
102+
},
103+
pageMessages: (sessionID, limit, before) => {
104+
const items = sessionID === fixture.targetID ? messages : fixture.messages[fixture.sourceID]
105+
const end = before ? items.findIndex((message) => message.info.id === before) : items.length
106+
const start = Math.max(0, end - limit)
107+
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
108+
},
109+
})
110+
await page.route(`**/session/${fixture.targetID}`, (route) =>
111+
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
112+
)
113+
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
114+
await page.goto(stressSessionHref(fixture.sourceID))
115+
await expectSessionTitle(page, fixture.expected.sourceTitle)
116+
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
117+
118+
const href = stressSessionHref(fixture.targetID)
119+
await page.evaluate(
120+
({ href, title }) => {
121+
const link = document.createElement("a")
122+
link.id = "parent-hydration-target"
123+
link.href = href
124+
link.textContent = title
125+
document.body.append(link)
126+
},
127+
{ href, title: target.title },
128+
)
129+
const metrics = await measureSessionSwitch(page, {
130+
destinationIDs: messages.map((message) => message.info.id),
131+
sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.info.id),
132+
lastID,
133+
requiredPartID: lastPartID,
134+
requireBottomAnchor: false,
135+
href,
136+
switch: async () => {
137+
await page.locator("#parent-hydration-target").click()
138+
await expectSessionTitle(page, target.title)
139+
},
140+
}).finally(() => history?.resolve())
141+
expect(metrics.firstCorrectObservedMs).not.toBeNull()
142+
const requestCounts = {
143+
list: requests.filter((request) => request.type === "list").length,
144+
parent: requests.filter((request) => request.type === "parent").length,
145+
}
146+
if (mode === "candidate") {
147+
expect(requestCounts.parent).toBe(1)
148+
expect(historyGates).toBe(1)
149+
}
150+
return { metrics, requestCounts, historyGateCount: historyGates }
151+
}

packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ export type SessionSwitchSample = {
44
source: string[]
55
hasVisibleRows: boolean
66
last: boolean
7+
requiredPartVisible?: boolean
8+
bottomAnchorRequired?: boolean
79
bottomErrorPx?: number
810
review?: {
911
fileHost: boolean
@@ -41,7 +43,8 @@ export function isCorrectDestination(sample: SessionSwitchSample) {
4143
sample.destination.length > 0 &&
4244
sample.source.length === 0 &&
4345
sample.last &&
44-
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1
46+
sample.requiredPartVisible !== false &&
47+
(sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1)
4548
)
4649
}
4750

packages/app/e2e/performance/timeline/session-tab-switch-probe.ts

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,16 @@ type SessionSwitchProbe = {
88

99
async function installSessionSwitchProbe(
1010
page: Page,
11-
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string },
11+
input: {
12+
destinationIDs: string[]
13+
sourceIDs: string[]
14+
lastID: string
15+
requiredPartID?: string
16+
requireBottomAnchor?: boolean
17+
href: string
18+
},
1219
) {
13-
await page.evaluate(({ destinationIDs, sourceIDs, lastID, href }) => {
20+
await page.evaluate(({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, href }) => {
1421
const destination = new Set(destinationIDs)
1522
const source = new Set(sourceIDs)
1623
const samples: SessionSwitchSample[] = []
@@ -66,18 +73,36 @@ async function installSessionSwitchProbe(
6673
const rect = element.getBoundingClientRect()
6774
return rect.bottom > view.top && rect.top < view.bottom
6875
})
76+
const requiredPartVisible = requiredPartID
77+
? [...root.querySelectorAll<HTMLElement>("[data-timeline-part-id]")].some((element) => {
78+
if (element.dataset.timelinePartId !== requiredPartID) return false
79+
const rect = element.getBoundingClientRect()
80+
return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
81+
})
82+
: undefined
6983
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
7084
samples.push({
7185
observedAtMs,
7286
destination: visible.filter((id) => destination.has(id)),
7387
source: visible.filter((id) => source.has(id)),
7488
hasVisibleRows,
7589
last: visible.includes(lastID),
90+
requiredPartVisible,
91+
bottomAnchorRequired: requireBottomAnchor !== false,
7692
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
7793
review,
7894
})
7995
} else {
80-
samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false, review })
96+
samples.push({
97+
observedAtMs,
98+
destination: [],
99+
source: [],
100+
hasVisibleRows: false,
101+
last: false,
102+
requiredPartVisible: requiredPartID ? false : undefined,
103+
bottomAnchorRequired: requireBottomAnchor !== false,
104+
review,
105+
})
81106
}
82107
requestAnimationFrame(sample)
83108
}, 0)
@@ -117,7 +142,8 @@ async function waitForStableSessionSwitch(page: Page) {
117142
sample.destination.length > 0 &&
118143
sample.source.length === 0 &&
119144
sample.last &&
120-
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1,
145+
sample.requiredPartVisible !== false &&
146+
(sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1),
121147
)
122148
)
123149
})
@@ -135,13 +161,27 @@ async function collectSessionSwitchResult(page: Page) {
135161

136162
export async function measureSessionSwitch(
137163
page: Page,
138-
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string; switch: () => Promise<void> },
164+
input: {
165+
destinationIDs: string[]
166+
sourceIDs: string[]
167+
lastID: string
168+
requiredPartID?: string
169+
requireBottomAnchor?: boolean
170+
href: string
171+
switch: () => Promise<void>
172+
},
139173
) {
140174
const { switch: run, ...probe } = input
141175
await installSessionSwitchProbe(page, probe)
142-
await run()
143-
await waitForStableSessionSwitch(page)
144-
return collectSessionSwitchResult(page)
176+
try {
177+
await run()
178+
await waitForStableSessionSwitch(page)
179+
return await collectSessionSwitchResult(page)
180+
} finally {
181+
await page.evaluate(() => {
182+
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.stop()
183+
})
184+
}
145185
}
146186

147187
export async function waitForStableTimeline(page: Page, lastID: string) {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { expect, test } from "bun:test"
2+
import type { Page, Route } from "@playwright/test"
3+
import { mockOpenCodeServer } from "../../utils/mock-server"
4+
5+
test("applies message latency after a list response gate is released", async () => {
6+
const events: string[] = []
7+
const gate = Promise.withResolvers<void>()
8+
let handler: ((route: Route) => Promise<void>) | undefined
9+
const page = {
10+
route: (_url: string, callback: (route: Route) => Promise<void>) => {
11+
handler = callback
12+
return Promise.resolve()
13+
},
14+
} as unknown as Page
15+
await mockOpenCodeServer(page, {
16+
provider: {},
17+
directory: "C:/OpenCode",
18+
project: {},
19+
sessions: [{ id: "session" }],
20+
messageDelay: 25,
21+
beforeMessagesResponse: () => {
22+
events.push("before")
23+
return gate.promise
24+
},
25+
onMessages: (request) => events.push(request.phase),
26+
pageMessages: () => {
27+
events.push("page")
28+
return { items: [] }
29+
},
30+
})
31+
32+
const response = handler!({
33+
request: () => ({ url: () => "http://127.0.0.1:4096/session/session/message" }),
34+
fulfill: () => {
35+
events.push("fulfill")
36+
return Promise.resolve()
37+
},
38+
} as unknown as Route)
39+
expect(events).toEqual(["start", "before"])
40+
41+
const released = performance.now()
42+
gate.resolve()
43+
await response
44+
expect(performance.now() - released).toBeGreaterThanOrEqual(20)
45+
expect(events).toEqual(["start", "before", "page", "end", "fulfill"])
46+
})

0 commit comments

Comments
 (0)