SaylorTwift HF Staff commited on
Commit
eb1202c
·
verified ·
1 Parent(s): 3e05655

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. packages/app/e2e/performance/AGENTS.md +13 -0
  2. packages/app/e2e/performance/README.md +79 -0
  3. packages/app/e2e/performance/benchmark.ts +144 -0
  4. packages/app/e2e/performance/chrome-trace.ts +95 -0
  5. packages/app/e2e/performance/playwright.config.ts +20 -0
  6. packages/app/e2e/performance/playwright.uncapped.config.ts +13 -0
  7. packages/app/e2e/performance/unit/session-timeline-stream-probe.test.ts +14 -0
  8. packages/app/e2e/performance/unit/session-timeline-visual-tracking.test.ts +16 -0
  9. packages/app/e2e/regression/cross-server-tab-close.spec.ts +153 -0
  10. packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts +187 -0
  11. packages/app/e2e/regression/new-session-panel-corner.spec.ts +83 -0
  12. packages/app/e2e/regression/open-file-expand-folder.spec.ts +132 -0
  13. packages/app/e2e/regression/project-picker-recent-search.spec.ts +60 -0
  14. packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts +50 -0
  15. packages/app/e2e/regression/prompt-thinking-level.spec.ts +84 -0
  16. packages/app/e2e/regression/remote-session-settings.spec.ts +302 -0
  17. packages/app/e2e/regression/remote-tab-busy.spec.ts +131 -0
  18. packages/app/e2e/regression/review-image-flash.spec.ts +202 -0
  19. packages/app/e2e/regression/review-line-comment.spec.ts +169 -0
  20. packages/app/e2e/regression/review-open-file.spec.ts +163 -0
  21. packages/app/e2e/regression/review-state-persistence.spec.ts +153 -0
  22. packages/app/e2e/regression/review-tab-switch.spec.ts +147 -0
  23. packages/app/e2e/regression/review-terminal-stacked.spec.ts +306 -0
  24. packages/app/e2e/regression/session-list-path-loading.spec.ts +41 -0
  25. packages/app/e2e/regression/session-rename.spec.ts +141 -0
  26. packages/app/e2e/regression/session-request-docks.spec.ts +221 -0
  27. packages/app/e2e/regression/session-timeline-accessibility.spec.ts +22 -0
  28. packages/app/e2e/regression/session-timeline-collapse-state.spec.ts +439 -0
  29. packages/app/e2e/regression/session-timeline-context-resize.spec.ts +374 -0
  30. packages/app/e2e/regression/session-timeline-context-state.spec.ts +31 -0
  31. packages/app/e2e/regression/session-timeline-file-projection.spec.ts +52 -0
  32. packages/app/e2e/regression/session-timeline-file-state.spec.ts +94 -0
  33. packages/app/e2e/regression/session-timeline-history-root.spec.ts +242 -0
  34. packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +111 -0
  35. packages/app/e2e/regression/session-timeline-locale-projection.spec.ts +25 -0
  36. packages/app/e2e/regression/session-timeline-projection.spec.ts +283 -0
  37. packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts +93 -0
  38. packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts +43 -0
  39. packages/app/e2e/regression/session-timeline-shell-outline.spec.ts +228 -0
  40. packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +99 -0
  41. packages/app/e2e/regression/session-timeline-tool-state.spec.ts +77 -0
  42. packages/app/e2e/regression/session-timeline-transport.spec.ts +154 -0
  43. packages/app/e2e/regression/session-todo-dock-navigation.spec.ts +190 -0
  44. packages/app/e2e/regression/subagent-child-navigation.spec.ts +203 -0
  45. packages/app/e2e/regression/tab-navigate-mousedown.spec.ts +159 -0
  46. packages/app/e2e/regression/terminal-composer-focus.spec.ts +227 -0
  47. packages/app/e2e/regression/terminal-hidden.spec.ts +117 -0
  48. packages/app/e2e/regression/terminal-tab-switch.spec.ts +165 -0
  49. packages/app/e2e/smoke/session-timeline.fixture.ts +315 -0
  50. packages/app/e2e/smoke/session-timeline.spec.ts +740 -0
packages/app/e2e/performance/AGENTS.md ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ - Prioritize stability, then simplicity, then measurement overhead.
2
+ - Use Playwright for scenario control, isolation, and completion checks.
3
+ - Use Chrome Performance traces for generic browser profiling.
4
+ - Use Electron `contentTracing` for packaged multi-process profiling.
5
+ - Keep custom probes only for product-specific measurements.
6
+ - Do not duplicate measurements across the harness, probes, and traces.
7
+ - Run benchmarks serially to avoid cross-test contention.
8
+ - Run benchmarks against production builds.
9
+ - Keep detailed profiling opt-in when it changes workload behavior.
10
+ - Preserve raw diagnostic data or use lossless representations.
11
+ - Do not enforce machine-dependent performance thresholds.
12
+ - Assert scenario completion and metric collection only.
13
+ - Keep normal test discovery free of manual benchmarks.
packages/app/e2e/performance/README.md ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Manual app performance suite
2
+
3
+ The app's high-volume performance diagnostics live under `packages/app/e2e/performance` and are excluded from normal local and CI Playwright discovery. The benchmark config builds the app and serves the production bundle before running scenarios serially.
4
+
5
+ Run the suite explicitly from `packages/app`:
6
+
7
+ ```sh
8
+ bun run test:bench
9
+ ```
10
+
11
+ PowerShell:
12
+
13
+ ```powershell
14
+ $env:PLAYWRIGHT_WORKERS = "1"
15
+ bun run test:bench
16
+ ```
17
+
18
+ The suite contains:
19
+
20
+ - cold and hot session-tab timing
21
+ - home-session click timing split between content and titlebar-tab paint
22
+ - single-session tab close timing through stable home restoration
23
+ - cached session repaint and mutation tracing
24
+ - streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
25
+
26
+ All benchmarks import the shared `benchmark` fixture. Pages created through Playwright's `page` fixture automatically capture main-frame navigation history and emit a Chrome trace when `OPENCODE_PERFORMANCE_TRACE_DIR` is set. Benchmarks that need isolated browser contexts use `withBenchmarkPage`, which owns the context and the same diagnostics lifecycle.
27
+
28
+ New benchmarks should look like normal Playwright tests:
29
+
30
+ ```ts
31
+ import { benchmark, expect } from "../benchmark"
32
+
33
+ benchmark("measures one interaction", async ({ page, report }) => {
34
+ // Only scenario-specific setup and interaction belong here.
35
+ report({ durationMs: 42 })
36
+ })
37
+ ```
38
+
39
+ The fixture requires every benchmark to call `report()`, automatically names and closes traces, captures navigation history, attaches that history when a test fails, and emits metrics as a consistent `BENCHMARK` JSON line.
40
+
41
+ ```text
42
+ BENCHMARK {"name":"...","context":{"project":"chromium","platform":"darwin"},"metrics":{...}}
43
+ ```
44
+
45
+ Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, and optional trace path before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide page-lifetime diagnostics; scenario metrics use narrower explicitly named observation windows.
46
+
47
+ This follows the stack's own guidance: [Electron recommends repeated Chrome DevTools and Chrome Tracing measurement](https://www.electronjs.org/docs/latest/tutorial/performance), [Chrome DevTools recommends Performance recordings for runtime work](https://developer.chrome.com/docs/devtools/performance), and [Playwright uses traces for test debugging rather than renderer profiling](https://playwright.dev/docs/trace-viewer).
48
+
49
+ These Playwright benchmarks profile the shared app renderer in Chromium. A future packaged Electron benchmark that needs main-process and multi-process attribution should use Electron's official [`contentTracing`](https://www.electronjs.org/docs/latest/api/content-tracing/) API rather than extending this renderer harness with bespoke process instrumentation.
50
+
51
+ CPU and high-volume visual profiling are disabled by default. Set `TIMELINE_CPU_PROFILE=1` to enable both, or additionally set `TIMELINE_VISUAL_PROFILE=0` for CPU-only profiling.
52
+
53
+ The streaming scenario's 30x CPU throttle is a deterministic stress profile, not a simulated end-user device.
54
+
55
+ Benchmarks do not assert machine-dependent performance budgets. Streaming processes 160 deltas by default and reports renderer-observed completion time, throughput, RAF callback-gap distributions, frame-budget equivalents, and long tasks through final geometry settlement. Delta count and delivery batch are included in result context when overridden. These are main-thread callback diagnostics, not compositor presentation or dropped-frame measurements. Visual-only and geometry metrics are `null` when their probes are disabled. Tab metrics describe sampled DOM observations. Assertions verify scenario and metric collection completion. Repeated repaint states are run-length grouped, but every original observation timestamp is retained alongside raw mutation batches and layout shifts.
56
+
57
+ Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing.
58
+
59
+ ## Chrome traces
60
+
61
+ Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically:
62
+
63
+ ```sh
64
+ OPENCODE_PERFORMANCE_TRACE_DIR=/tmp/opencode-performance-traces \
65
+ bunx playwright test --config e2e/performance/playwright.config.ts \
66
+ timeline/session-tab-switch-benchmark.spec.ts
67
+ ```
68
+
69
+ The emitted JSON is a standard Chrome trace and can be loaded directly into the Chrome DevTools Performance panel. `devtools-tracing` can optionally inspect it from the command line without adding package scripts or dependencies:
70
+
71
+ Trace capture mirrors [Puppeteer's official tracing defaults and lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss.
72
+
73
+ ```sh
74
+ bunx devtools-tracing stats <trace-path-from-BENCHMARK_PAGE>
75
+ ```
76
+
77
+ INP analysis requires a trace with a supported navigation/interaction insight. Selector statistics require a trace captured with `OPENCODE_PERFORMANCE_SELECTOR_TRACE=1`.
78
+
79
+ `e2e/performance/playwright.uncapped.config.ts` disables Chromium frame-rate limiting for explicit uncapped diagnostics. Native product benchmarks should use the default Playwright configuration.
packages/app/e2e/performance/benchmark.ts ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test as base, type Browser, type Page, type TestInfo } from "@playwright/test"
2
+ import { startChromeTrace } from "./chrome-trace"
3
+
4
+ type BenchmarkFixtures = {
5
+ report: (metrics: Record<string, unknown>, context?: Record<string, unknown>) => void
6
+ reportState: { payload?: { metrics: Record<string, unknown>; context: Record<string, unknown> } }
7
+ benchmarkResult: void
8
+ }
9
+
10
+ export type PerformancePageDiagnostics = {
11
+ navigations: string[]
12
+ stop: () => Promise<string | undefined>
13
+ }
14
+
15
+ const pages = new WeakMap<Page, PerformancePageDiagnostics>()
16
+
17
+ export const benchmark = base.extend<BenchmarkFixtures>({
18
+ reportState: async ({}, use) => use({}),
19
+ report: async ({ reportState }, use) => {
20
+ await use((metrics, context = {}) => {
21
+ if (reportState.payload) throw new Error("Benchmark reported metrics more than once")
22
+ reportState.payload = { metrics, context }
23
+ })
24
+ },
25
+ benchmarkResult: [
26
+ async ({ reportState }, use, testInfo) => {
27
+ await use()
28
+ const missing = !reportState.payload
29
+ console.log(
30
+ `BENCHMARK ${JSON.stringify({
31
+ schemaVersion: 2,
32
+ runID: process.env.OPENCODE_PERFORMANCE_RUN_ID,
33
+ name: benchmarkName(testInfo),
34
+ status: missing ? "failed" : testInfo.status,
35
+ expectedStatus: testInfo.expectedStatus,
36
+ retry: testInfo.retry,
37
+ repeatEachIndex: testInfo.repeatEachIndex,
38
+ context: {
39
+ project: testInfo.project.name,
40
+ platform: process.platform,
41
+ ...reportState.payload?.context,
42
+ },
43
+ metrics: reportState.payload?.metrics ?? null,
44
+ error: missing ? "Benchmark did not report metrics" : undefined,
45
+ })}`,
46
+ )
47
+ if (missing && testInfo.status === testInfo.expectedStatus)
48
+ throw new Error(`Benchmark did not report metrics: ${benchmarkName(testInfo)}`)
49
+ },
50
+ { auto: true },
51
+ ],
52
+ page: async ({ page }, use, testInfo) => {
53
+ const name = benchmarkName(testInfo)
54
+ const diagnostics = await observePerformancePage(page, name)
55
+ try {
56
+ await use(page)
57
+ } finally {
58
+ try {
59
+ await reportPerformancePage(name, diagnostics, testInfo)
60
+ } finally {
61
+ if (testInfo.status !== testInfo.expectedStatus) {
62
+ await testInfo.attach("performance-navigations", {
63
+ body: JSON.stringify(diagnostics.navigations, null, 2),
64
+ contentType: "application/json",
65
+ })
66
+ }
67
+ }
68
+ }
69
+ },
70
+ })
71
+
72
+ function benchmarkName(testInfo: TestInfo) {
73
+ return testInfo.titlePath.slice(1).join(" > ")
74
+ }
75
+
76
+ export { expect }
77
+
78
+ async function observePerformancePage(page: Page, name: string) {
79
+ const navigations: string[] = []
80
+ const onNavigation = (frame: ReturnType<Page["mainFrame"]>) => {
81
+ if (frame === page.mainFrame()) navigations.push(frame.url())
82
+ }
83
+ page.on("framenavigated", onNavigation)
84
+ const stopTrace = await startChromeTrace(page, name).catch((error) => {
85
+ page.off("framenavigated", onNavigation)
86
+ throw error
87
+ })
88
+ let stopping: Promise<string | undefined> | undefined
89
+ const diagnostics: PerformancePageDiagnostics = {
90
+ navigations,
91
+ stop() {
92
+ page.off("framenavigated", onNavigation)
93
+ return (stopping ??= stopTrace?.() ?? Promise.resolve(undefined))
94
+ },
95
+ }
96
+ pages.set(page, diagnostics)
97
+ return diagnostics
98
+ }
99
+
100
+ export async function withBenchmarkPage<T>(
101
+ browser: Browser,
102
+ name: string,
103
+ run: (page: Page) => Promise<T>,
104
+ testInfo?: TestInfo,
105
+ ) {
106
+ const context = await browser.newContext()
107
+ try {
108
+ const page = await context.newPage()
109
+ const diagnostics = await observePerformancePage(page, name)
110
+ try {
111
+ return await run(page)
112
+ } finally {
113
+ await reportPerformancePage(name, diagnostics, testInfo)
114
+ }
115
+ } finally {
116
+ await context.close()
117
+ }
118
+ }
119
+
120
+ async function reportPerformancePage(name: string, diagnostics: PerformancePageDiagnostics, testInfo?: TestInfo) {
121
+ const trace = await diagnostics.stop()
122
+ console.log(
123
+ `BENCHMARK_PAGE ${JSON.stringify({
124
+ schemaVersion: 2,
125
+ runID: process.env.OPENCODE_PERFORMANCE_RUN_ID,
126
+ name,
127
+ test: testInfo ? benchmarkName(testInfo) : undefined,
128
+ retry: testInfo?.retry,
129
+ repeatEachIndex: testInfo?.repeatEachIndex,
130
+ context: {
131
+ platform: process.platform,
132
+ trace,
133
+ selectorTrace: process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1",
134
+ },
135
+ navigations: diagnostics.navigations,
136
+ })}`,
137
+ )
138
+ }
139
+
140
+ export function benchmarkDiagnostics(page: Page) {
141
+ const diagnostics = pages.get(page)
142
+ if (!diagnostics) throw new Error("Performance diagnostics are not installed for this page")
143
+ return diagnostics
144
+ }
packages/app/e2e/performance/chrome-trace.ts ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { CDPSession, Page } from "@playwright/test"
2
+ import path from "node:path"
3
+ import { mkdir, open, rename } from "node:fs/promises"
4
+ import { Buffer } from "node:buffer"
5
+ import { createHash, randomUUID } from "node:crypto"
6
+
7
+ const categories = [
8
+ "-*",
9
+ "devtools.timeline",
10
+ "v8.execute",
11
+ "disabled-by-default-devtools.timeline",
12
+ "disabled-by-default-devtools.timeline.frame",
13
+ "toplevel",
14
+ "blink.console",
15
+ "blink.user_timing",
16
+ "latencyInfo",
17
+ "disabled-by-default-devtools.timeline.stack",
18
+ "disabled-by-default-v8.cpu_profiler",
19
+ ]
20
+
21
+ export async function startChromeTrace(page: Page, name: string) {
22
+ const directory = process.env.OPENCODE_PERFORMANCE_TRACE_DIR
23
+ if (!directory) return
24
+
25
+ const selectors = process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1"
26
+ const file = await prepareChromeTrace(directory, name, selectors)
27
+ const session = await page.context().newCDPSession(page)
28
+ try {
29
+ await session.send("Tracing.start", {
30
+ transferMode: "ReturnAsStream",
31
+ traceConfig: {
32
+ excludedCategories: categories
33
+ .filter((category) => category.startsWith("-"))
34
+ .map((category) => category.slice(1)),
35
+ includedCategories: [
36
+ ...categories.filter((category) => !category.startsWith("-")),
37
+ ...(selectors
38
+ ? ["disabled-by-default-blink.debug", "disabled-by-default-devtools.timeline.invalidationTracking"]
39
+ : []),
40
+ ],
41
+ },
42
+ })
43
+ } catch (error) {
44
+ await Promise.allSettled([session.detach()])
45
+ throw error
46
+ }
47
+ let stopping: Promise<string> | undefined
48
+
49
+ return () =>
50
+ (stopping ??= (async () => {
51
+ try {
52
+ const complete = new Promise<{ stream?: string; dataLossOccurred: boolean }>((resolve) =>
53
+ session.once("Tracing.tracingComplete", resolve),
54
+ )
55
+ await session.send("Tracing.end")
56
+ const result = await complete
57
+ if (!result.stream) throw new Error(`Chrome trace stream missing: ${file}`)
58
+ const partial = `${file}.partial`
59
+ await writeProtocolStream(session, result.stream, partial)
60
+ if (result.dataLossOccurred) throw new Error(`Chrome trace lost data; partial capture retained: ${partial}`)
61
+ await rename(partial, file)
62
+ return file
63
+ } finally {
64
+ await Promise.allSettled([session.detach()])
65
+ }
66
+ })())
67
+ }
68
+
69
+ export async function prepareChromeTrace(
70
+ directory: string,
71
+ name: string,
72
+ selectors: boolean,
73
+ nonce = randomUUID().slice(0, 8),
74
+ ) {
75
+ await mkdir(directory, { recursive: true })
76
+ const run = process.env.OPENCODE_PERFORMANCE_RUN_ID ?? "manual"
77
+ const hash = createHash("sha256").update(name).digest("hex").slice(0, 8)
78
+ return path.join(
79
+ directory,
80
+ `${run}-${name.replace(/[^a-zA-Z0-9_-]/g, "-")}-${hash}-${nonce}${selectors ? "-selectors" : ""}.json`,
81
+ )
82
+ }
83
+
84
+ async function writeProtocolStream(session: CDPSession, handle: string, file: string) {
85
+ const output = await open(file, "wx")
86
+ try {
87
+ while (true) {
88
+ const chunk = await session.send("IO.read", { handle })
89
+ await output.write(chunk.base64Encoded ? Buffer.from(chunk.data, "base64") : chunk.data)
90
+ if (chunk.eof) break
91
+ }
92
+ } finally {
93
+ await Promise.allSettled([output.close(), session.send("IO.close", { handle })])
94
+ }
95
+ }
packages/app/e2e/performance/playwright.config.ts ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import config from "../../playwright.config"
2
+
3
+ const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
4
+ process.env.PLAYWRIGHT_SERVER_PORT = String(port)
5
+ process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}`
6
+
7
+ export default {
8
+ ...config,
9
+ testDir: ".",
10
+ testIgnore: "unit/**",
11
+ outputDir: "../test-results/performance",
12
+ fullyParallel: false,
13
+ workers: 1,
14
+ reporter: [["html", { outputFolder: "../playwright-report/performance", open: "never" }], ["line"]],
15
+ webServer: {
16
+ ...config.webServer,
17
+ command: `bun run build && bun run serve -- --host 0.0.0.0 --port ${port} --strictPort`,
18
+ reuseExistingServer: false,
19
+ },
20
+ }
packages/app/e2e/performance/playwright.uncapped.config.ts ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import config from "./playwright.config"
2
+
3
+ export default {
4
+ ...config,
5
+ outputDir: "../test-results/performance-uncapped",
6
+ reporter: [["html", { outputFolder: "../playwright-report/performance-uncapped", open: "never" }], ["line"]],
7
+ use: {
8
+ ...config.use,
9
+ launchOptions: {
10
+ args: ["--disable-frame-rate-limit", "--disable-gpu-vsync"],
11
+ },
12
+ },
13
+ }
packages/app/e2e/performance/unit/session-timeline-stream-probe.test.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "bun:test"
2
+ import { streamChunk } from "../timeline/session-timeline-benchmark.fixture"
3
+ import { streamProgress } from "../timeline/session-timeline-stream-probe"
4
+
5
+ test("classifies emitted stream markers using the fixture cycle", () => {
6
+ expect(streamProgress("before stream-17 after stream-18")).toEqual({ index: 18, phase: "boundary" })
7
+ expect(streamProgress("before stream-18 after stream-19")).toEqual({ index: 19, phase: "stream" })
8
+ expect(streamProgress("benchmark-complete stream-36")).toEqual({ index: 36, phase: "complete" })
9
+ expect(streamProgress("no marker")).toEqual({ index: -1, phase: "unknown" })
10
+ })
11
+
12
+ test("emits progress markers at fixture boundaries", () => {
13
+ expect(streamProgress(streamChunk(18, 160))).toEqual({ index: 18, phase: "boundary" })
14
+ })
packages/app/e2e/performance/unit/session-timeline-visual-tracking.test.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "bun:test"
2
+ import { layoutShiftValue, removeVisibleRow } from "../timeline/session-timeline-stream-probe"
3
+
4
+ test("excludes layout shifts before the probe window and recent input", () => {
5
+ expect(layoutShiftValue({ startTime: 9, value: 0.1 }, 10)).toBeUndefined()
6
+ expect(layoutShiftValue({ startTime: 10, value: 0.2, hadRecentInput: true }, 10)).toBeUndefined()
7
+ expect(layoutShiftValue({ startTime: 11, value: 0.3 }, 10)).toBe(0.3)
8
+ })
9
+
10
+ test("classifies removed rows from their last painted visibility", () => {
11
+ const row = {}
12
+ const visible = new Set([row])
13
+
14
+ expect(removeVisibleRow(visible, row)).toBe(true)
15
+ expect(removeVisibleRow(visible, row)).toBe(false)
16
+ })
packages/app/e2e/regression/cross-server-tab-close.spec.ts ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test, type Page, type Route } from "@playwright/test"
2
+ import { base64Encode } from "@opencode-ai/core/util/encode"
3
+ import { currentSession } from "../utils/mock-server"
4
+
5
+ const serverA = "http://127.0.0.1:4096"
6
+ const serverB = "http://127.0.0.1:4097"
7
+ const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
8
+ const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
9
+
10
+ test("closing the active server's last tab opens the remaining server tab", async ({ page }) => {
11
+ const requests: string[] = []
12
+ await mockServers(page, requests)
13
+ await page.addInitScript(
14
+ ({ serverB, sessionA, sessionB }) => {
15
+ localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
16
+ localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
17
+ localStorage.setItem(
18
+ "opencode.window.browser.dat:tabs",
19
+ JSON.stringify([
20
+ { type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA },
21
+ { type: "session", server: serverB, sessionId: sessionB },
22
+ ]),
23
+ )
24
+ },
25
+ { serverB, sessionA: sessionA.id, sessionB: sessionB.id },
26
+ )
27
+
28
+ const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
29
+ const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
30
+ await page.goto(hrefA)
31
+ await expect(page.getByText(sessionA.title).first()).toBeVisible()
32
+
33
+ const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
34
+ await tabA.locator('[data-slot="tab-close"] button').click()
35
+
36
+ await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
37
+ await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/session/${sessionB.id}`))).toBe(true)
38
+ await expect(page.getByText(sessionB.title).first()).toBeVisible()
39
+ const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
40
+ expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
41
+ expect(
42
+ requests.some((request) => {
43
+ const url = new URL(request)
44
+ return url.origin === serverB && url.searchParams.get("directory") === sessionB.directory
45
+ }),
46
+ ).toBe(true)
47
+ })
48
+
49
+ test("legacy session routes preserve an existing tab's server", async ({ page }) => {
50
+ await mockServers(page, [])
51
+ await page.addInitScript(
52
+ ({ serverB, sessionB }) => {
53
+ localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
54
+ localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
55
+ localStorage.setItem(
56
+ "opencode.window.browser.dat:tabs",
57
+ JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]),
58
+ )
59
+ },
60
+ { serverB, sessionB: sessionB.id },
61
+ )
62
+
63
+ const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
64
+ await page.goto(`/${base64Encode(sessionB.directory)}/session/${sessionB.id}`)
65
+ await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
66
+ })
67
+
68
+ function session(id: string, directory: string, title: string) {
69
+ return {
70
+ id,
71
+ slug: id,
72
+ projectID: `project-${id}`,
73
+ directory,
74
+ title,
75
+ version: "dev",
76
+ time: { created: 1, updated: 1 },
77
+ }
78
+ }
79
+
80
+ async function mockServers(page: Page, requests: string[]) {
81
+ await page.route("**/*", async (route) => {
82
+ const url = new URL(route.request().url())
83
+ if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
84
+ requests.push(url.toString())
85
+ const current = url.origin === serverA ? sessionA : sessionB
86
+ const directory = url.searchParams.get("directory")
87
+ if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
88
+ if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
89
+ return sse(route)
90
+ if (url.pathname === "/global/health") return json(route, {}, 404)
91
+ if (url.pathname === "/api/health") return json(route, { pid: 1 })
92
+ if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
93
+ if (url.pathname === "/api/session/active") return json(route, { data: {} })
94
+ if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
95
+ if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
96
+ if (url.pathname === `/session/${current.id}`) return json(route, current)
97
+ if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
98
+ if (url.pathname === `/session/${current.id}/message`) return json(route, [])
99
+ if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
100
+ if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
101
+ return json(route, [])
102
+ if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
103
+ if (url.pathname === "/provider")
104
+ return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
105
+ if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
106
+ if (url.pathname === "/project" || url.pathname === "/project/current") {
107
+ const project = {
108
+ id: current.projectID,
109
+ worktree: current.directory,
110
+ vcs: "git",
111
+ time: { created: 1, updated: 1 },
112
+ sandboxes: [],
113
+ }
114
+ return json(route, url.pathname === "/project" ? [project] : project)
115
+ }
116
+ if (url.pathname === "/path")
117
+ return json(route, {
118
+ state: current.directory,
119
+ config: current.directory,
120
+ worktree: current.directory,
121
+ directory: current.directory,
122
+ home: current.directory,
123
+ })
124
+ if (url.pathname === "/api/path")
125
+ return json(route, {
126
+ state: current.directory,
127
+ config: current.directory,
128
+ worktree: current.directory,
129
+ directory: current.directory,
130
+ home: current.directory,
131
+ })
132
+ if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
133
+ if (url.pathname === "/api/vcs")
134
+ return json(route, {
135
+ location: { directory: current.directory },
136
+ data: { branch: "main", defaultBranch: "main" },
137
+ })
138
+ return json(route, {})
139
+ })
140
+ }
141
+
142
+ function json(route: Route, body: unknown, status = 200) {
143
+ return route.fulfill({
144
+ status,
145
+ contentType: "application/json",
146
+ headers: { "access-control-allow-origin": "*" },
147
+ body: JSON.stringify(body),
148
+ })
149
+ }
150
+
151
+ function sse(route: Route) {
152
+ return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
153
+ }
packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { base64Encode } from "@opencode-ai/core/util/encode"
2
+ import { expect, test, type Page } from "@playwright/test"
3
+ import { mockOpenCodeServer } from "../utils/mock-server"
4
+ import { expectSessionTitle } from "../utils/waits"
5
+
6
+ const directory = "C:/OpenCode/FileBrowserSidebar"
7
+ const projectID = "proj_file_browser_sidebar"
8
+ const sessionID = "ses_file_browser_sidebar"
9
+ const title = "File browser sidebar"
10
+ const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
11
+ const files = Array.from({ length: 80 }, (_, index) => `file-${String(index).padStart(2, "0")}.ts`)
12
+ // Marks the file-browser sidebar DOM node so a remount (fresh node) is detectable.
13
+ const PROBE = "original"
14
+
15
+ test.use({ viewport: { width: 1440, height: 900 } })
16
+
17
+ // The file-browser sidebar must stay mounted across preview/pinned file-tab
18
+ // switches. Remounting resets scroll and filter state.
19
+ test("keeps the file-browser sidebar mounted when switching file tabs", async ({ page }) => {
20
+ await setup(page)
21
+
22
+ await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
23
+ await expectSessionTitle(page, title)
24
+
25
+ const panel = page.locator("#review-panel")
26
+ await panel.getByRole("button", { name: "Open file" }).click()
27
+ await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
28
+
29
+ const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
30
+ await expect(sidebar).toBeVisible()
31
+ await expect(panel.getByRole("button", { name: "file-00.ts" })).toBeVisible()
32
+
33
+ await panel.getByRole("button", { name: "file-00.ts" }).click()
34
+ await expect(panel.getByRole("tab", { name: "file-00.ts" })).toHaveAttribute("data-selected", "")
35
+ await expect(panel.getByText("contents:file-00.ts", { exact: true })).toBeVisible()
36
+
37
+ const viewport = panel.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
38
+ await viewport.hover()
39
+ await page.mouse.wheel(0, 100_000)
40
+ await expect
41
+ .poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
42
+ .toBeLessThanOrEqual(1)
43
+ const scrolled = await viewport.evaluate((element) => element.scrollTop)
44
+ expect(scrolled).toBeGreaterThan(0)
45
+ await writeProbe(page)
46
+
47
+ await panel.getByRole("button", { name: "file-79.ts" }).click()
48
+ await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
49
+ await expect(panel.getByText("contents:file-79.ts", { exact: true })).toBeVisible()
50
+ expect(await readProbe(page)).toBe(PROBE)
51
+ await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
52
+
53
+ await panel.getByRole("button", { name: "file-78.ts" }).dblclick()
54
+ await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
55
+ await panel.getByRole("button", { name: "file-79.ts" }).click()
56
+ await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
57
+ await panel.getByRole("tab", { name: "file-78.ts" }).click()
58
+ await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
59
+ expect(await readProbe(page)).toBe(PROBE)
60
+ await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
61
+ })
62
+
63
+ test("keeps previous file search results visible while the next search loads", async ({ page }) => {
64
+ const searchPending = Promise.withResolvers<void>()
65
+ await setup(page, async ({ query }) => {
66
+ if (query === "file-0") return ["file-00.ts"]
67
+ if (query === "file-7") {
68
+ await searchPending.promise
69
+ return ["file-79.ts"]
70
+ }
71
+ return []
72
+ })
73
+
74
+ await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
75
+ await expectSessionTitle(page, title)
76
+
77
+ const panel = page.locator("#review-panel")
78
+ await panel.getByRole("button", { name: "Open file" }).click()
79
+ const filter = panel.getByRole("combobox", { name: "Filter files" })
80
+ await filter.fill("file-0")
81
+ await expect(panel.getByRole("option", { name: "file-00.ts" })).toBeVisible()
82
+
83
+ const nextSearch = page.waitForRequest((request) => {
84
+ const url = new URL(request.url())
85
+ return url.pathname === "/find/file" && url.searchParams.get("query") === "file-7"
86
+ })
87
+ await filter.fill("file-7")
88
+ await nextSearch
89
+ await expect(panel.getByRole("option", { name: "file-00.ts" })).toBeVisible()
90
+
91
+ searchPending.resolve()
92
+ await expect(panel.getByRole("option", { name: "file-79.ts" })).toBeVisible()
93
+ await expect(panel.getByRole("option", { name: "file-00.ts" })).toBeHidden()
94
+ })
95
+
96
+ type Probed = HTMLElement & { __e2eProbe?: string }
97
+
98
+ async function writeProbe(page: Page) {
99
+ await page.locator('#review-panel [data-component="session-review-v2-sidebar-root"]').evaluate((el, probe) => {
100
+ ;(el as Probed).__e2eProbe = probe
101
+ }, PROBE)
102
+ }
103
+
104
+ async function readProbe(page: Page) {
105
+ return page
106
+ .locator('#review-panel [data-component="session-review-v2-sidebar-root"]')
107
+ .evaluate((el) => (el as Probed).__e2eProbe)
108
+ }
109
+
110
+ async function setup(
111
+ page: Page,
112
+ findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown | Promise<unknown>,
113
+ ) {
114
+ await mockOpenCodeServer(page, {
115
+ directory,
116
+ project: {
117
+ id: projectID,
118
+ worktree: directory,
119
+ vcs: "git",
120
+ name: "file-browser-sidebar",
121
+ time: { created: 1700000000000, updated: 1700000000000 },
122
+ sandboxes: [],
123
+ },
124
+ provider: {
125
+ all: [
126
+ {
127
+ id: "opencode",
128
+ name: "OpenCode",
129
+ models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
130
+ },
131
+ ],
132
+ connected: ["opencode"],
133
+ default: { providerID: "opencode", modelID: "test" },
134
+ },
135
+ sessions: [
136
+ {
137
+ id: sessionID,
138
+ slug: sessionID,
139
+ projectID,
140
+ directory,
141
+ title,
142
+ version: "dev",
143
+ time: { created: 1700000000000, updated: 1700000000000 },
144
+ },
145
+ ],
146
+ vcsDiff: [],
147
+ fileList: (path) => {
148
+ if (path) return []
149
+ return files.map((name) => ({
150
+ name,
151
+ path: name,
152
+ absolute: `${directory}/${name}`,
153
+ type: "file" as const,
154
+ ignored: false,
155
+ }))
156
+ },
157
+ fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
158
+ findFiles,
159
+ pageMessages: () => ({ items: [] }),
160
+ })
161
+
162
+ await page.addInitScript(
163
+ ({ directory, server, sessionID }) => {
164
+ localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
165
+ localStorage.setItem(
166
+ "opencode.global.dat:server",
167
+ JSON.stringify({
168
+ projects: { local: [{ worktree: directory, expanded: true }] },
169
+ lastProject: { local: directory },
170
+ }),
171
+ )
172
+ localStorage.setItem(
173
+ "opencode.global.dat:layout",
174
+ JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
175
+ )
176
+ localStorage.setItem(
177
+ "opencode.global.dat:review-panel-v2",
178
+ JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
179
+ )
180
+ localStorage.setItem(
181
+ "opencode.window.browser.dat:tabs",
182
+ JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
183
+ )
184
+ },
185
+ { directory, server, sessionID },
186
+ )
187
+ }
packages/app/e2e/regression/new-session-panel-corner.spec.ts ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import { mockOpenCodeServer } from "../utils/mock-server"
3
+ import { expectAppVisible } from "../utils/waits"
4
+
5
+ const draftID = "draft_new_session_panel_corner"
6
+ const directory = "C:/OpenCode/NewSessionPanelCorner"
7
+ const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
8
+
9
+ test.use({
10
+ viewport: { width: 935, height: 522 },
11
+ deviceScaleFactor: 1,
12
+ })
13
+
14
+ test("matches the rounded panel corners to the dark new-session background", async ({ page }, testInfo) => {
15
+ await mockOpenCodeServer(page, {
16
+ directory,
17
+ project: {
18
+ id: "proj_new_session_panel_corner",
19
+ worktree: directory,
20
+ vcs: "git",
21
+ name: "new-session-panel-corner",
22
+ time: { created: 1700000000000, updated: 1700000000000 },
23
+ sandboxes: [],
24
+ },
25
+ provider: { all: [], connected: [], default: {} },
26
+ sessions: [],
27
+ pageMessages: () => ({ items: [] }),
28
+ })
29
+ await page.addInitScript(
30
+ ({ directory, draftID, server }) => {
31
+ localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
32
+ localStorage.setItem("opencode-theme-id", "oc-2")
33
+ localStorage.setItem("opencode-color-scheme", "dark")
34
+ localStorage.setItem(
35
+ "opencode.global.dat:server",
36
+ JSON.stringify({
37
+ projects: { local: [{ worktree: directory, expanded: true }] },
38
+ lastProject: { local: directory },
39
+ }),
40
+ )
41
+ localStorage.setItem(
42
+ "opencode.window.browser.dat:tabs",
43
+ JSON.stringify([{ type: "draft", draftID, server, directory }]),
44
+ )
45
+ },
46
+ { directory, draftID, server },
47
+ )
48
+
49
+ await page.goto(`/new-session?draftId=${draftID}`)
50
+ await expectAppVisible(page.locator('[data-component="prompt-input"]'))
51
+ await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
52
+ const panel = page.locator('main div[class*="rounded-[10px]"][class*="overflow-hidden"]')
53
+ await expect(panel).toHaveCount(1)
54
+ const box = await panel.boundingBox()
55
+ if (!box) throw new Error("New-session panel bounds are unavailable")
56
+
57
+ const screenshot = await page.screenshot({ path: testInfo.outputPath("new-session-dark.png") })
58
+ const corners = await page.evaluate(
59
+ async ({ source, points }) => {
60
+ const image = new Image()
61
+ image.src = source
62
+ await image.decode()
63
+ const canvas = document.createElement("canvas")
64
+ canvas.width = image.naturalWidth
65
+ canvas.height = image.naturalHeight
66
+ const context = canvas.getContext("2d")
67
+ if (!context) throw new Error("2D canvas is unavailable")
68
+ context.drawImage(image, 0, 0)
69
+ return points.map((point) => Array.from(context.getImageData(point.x, point.y, 1, 1).data))
70
+ },
71
+ {
72
+ source: `data:image/png;base64,${screenshot.toString("base64")}`,
73
+ points: [
74
+ { x: Math.floor(box.x), y: Math.floor(box.y) },
75
+ { x: Math.ceil(box.x + box.width) - 1, y: Math.floor(box.y) },
76
+ { x: Math.floor(box.x), y: Math.ceil(box.y + box.height) - 1 },
77
+ { x: Math.ceil(box.x + box.width) - 1, y: Math.ceil(box.y + box.height) - 1 },
78
+ ],
79
+ },
80
+ )
81
+
82
+ expect(corners.every(([red, green, blue, alpha]) => red <= 8 && green <= 8 && blue <= 8 && alpha === 255)).toBe(true)
83
+ })
packages/app/e2e/regression/open-file-expand-folder.spec.ts ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { base64Encode } from "@opencode-ai/core/util/encode"
2
+ import { expect, test } from "@playwright/test"
3
+ import { mockOpenCodeServer } from "../utils/mock-server"
4
+ import { expectSessionTitle } from "../utils/waits"
5
+
6
+ const directory = "C:/OpenCode/OpenFileExpand"
7
+ const projectID = "proj_open_file_expand"
8
+ const sessionID = "ses_open_file_expand"
9
+ const title = "Open file expand"
10
+ const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
11
+
12
+ test.use({ viewport: { width: 1440, height: 900 } })
13
+
14
+ test("expands a folder whose path has a trailing Windows separator", async ({ page }) => {
15
+ await mockOpenCodeServer(page, {
16
+ directory,
17
+ project: {
18
+ id: projectID,
19
+ worktree: directory,
20
+ vcs: "git",
21
+ name: "open-file-expand",
22
+ time: { created: 1700000000000, updated: 1700000000000 },
23
+ sandboxes: [],
24
+ },
25
+ provider: {
26
+ all: [
27
+ {
28
+ id: "opencode",
29
+ name: "OpenCode",
30
+ models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
31
+ },
32
+ ],
33
+ connected: ["opencode"],
34
+ default: { providerID: "opencode", modelID: "test" },
35
+ },
36
+ sessions: [
37
+ {
38
+ id: sessionID,
39
+ slug: sessionID,
40
+ projectID,
41
+ directory,
42
+ title,
43
+ version: "dev",
44
+ time: { created: 1700000000000, updated: 1700000000000 },
45
+ },
46
+ ],
47
+ vcsDiff: [],
48
+ fileList: (path) => {
49
+ if (path === "frontend\\" || path === "frontend") {
50
+ return [
51
+ {
52
+ name: "app.ts",
53
+ path: "frontend\\app.ts",
54
+ absolute: `${directory}/frontend/app.ts`,
55
+ type: "file" as const,
56
+ ignored: false,
57
+ },
58
+ ]
59
+ }
60
+ if (path) return []
61
+ return [
62
+ {
63
+ name: "frontend",
64
+ path: "frontend\\",
65
+ absolute: `${directory}/frontend`,
66
+ type: "directory" as const,
67
+ ignored: false,
68
+ },
69
+ {
70
+ name: "README.md",
71
+ path: "README.md",
72
+ absolute: `${directory}/README.md`,
73
+ type: "file" as const,
74
+ ignored: false,
75
+ },
76
+ ]
77
+ },
78
+ fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
79
+ pageMessages: () => ({ items: [] }),
80
+ })
81
+
82
+ await page.addInitScript(
83
+ ({ directory, server, sessionID }) => {
84
+ localStorage.setItem(
85
+ "settings.v3",
86
+ JSON.stringify({ general: { newLayoutDesigns: true, shouldDisplayTabsToast: false } }),
87
+ )
88
+ localStorage.setItem(
89
+ "opencode.global.dat:server",
90
+ JSON.stringify({
91
+ projects: { local: [{ worktree: directory, expanded: true }] },
92
+ lastProject: { local: directory },
93
+ }),
94
+ )
95
+ localStorage.setItem(
96
+ "opencode.global.dat:layout",
97
+ JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
98
+ )
99
+ localStorage.setItem(
100
+ "opencode.global.dat:review-panel-v2",
101
+ JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
102
+ )
103
+ localStorage.setItem(
104
+ "opencode.window.browser.dat:tabs",
105
+ JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
106
+ )
107
+ },
108
+ { directory, server, sessionID },
109
+ )
110
+
111
+ await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
112
+ await expectSessionTitle(page, title)
113
+
114
+ const panel = page.locator("#review-panel")
115
+ await panel.getByRole("button", { name: "Open file" }).click()
116
+ await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
117
+
118
+ const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
119
+ await expect(sidebar).toBeVisible()
120
+
121
+ const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]')
122
+ await expect(frontendRow).toBeVisible()
123
+ await expect(frontendRow).toHaveAttribute("aria-expanded", "false")
124
+ await frontendRow.click()
125
+ await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
126
+
127
+ const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]')
128
+ await expect(appRow).toBeVisible()
129
+ await appRow.click()
130
+ await expect(panel.getByRole("tab", { name: "app.ts" })).toHaveAttribute("data-selected", "")
131
+ await expect(panel.getByText("contents:frontend/app.ts", { exact: true })).toBeVisible()
132
+ })
packages/app/e2e/regression/project-picker-recent-search.spec.ts ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import type { Page } from "@playwright/test"
3
+ import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
4
+ import { mockOpenCodeServer } from "../utils/mock-server"
5
+ import { expectAppVisible } from "../utils/waits"
6
+
7
+ const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"]
8
+ const worktrees = NAMES.map((name) => `/opencode-demo/${name}`)
9
+
10
+ // The sixth project sits outside the five-item recent cap, so it is only reachable if the
11
+ // dialog hands every recent project to the list filter instead of a pre-truncated slice.
12
+ const OUTSIDE_CAP = "foxtrot-docs"
13
+
14
+ // Dialog rows carry data-directory-path; the sidebar project list does not, so this
15
+ // scopes assertions to the picker instead of matching the sidebar entry of the same name.
16
+ const rows = (page: Page) => page.locator("[data-directory-path]")
17
+ const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`)
18
+
19
+ async function openProjectDialog(page: Page) {
20
+ await mockOpenCodeServer(page, {
21
+ sessions: fixture.sessions,
22
+ provider: fixture.provider,
23
+ directory: fixture.directory,
24
+ project: fixture.project,
25
+ pageMessages,
26
+ fileList: () => [],
27
+ findFiles: () => [],
28
+ })
29
+ await page.addInitScript((dirs) => {
30
+ localStorage.setItem(
31
+ "opencode.global.dat:server",
32
+ JSON.stringify({
33
+ projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) },
34
+ lastProject: {},
35
+ }),
36
+ )
37
+ }, worktrees)
38
+ await page.goto("/")
39
+ const add = page.getByRole("button", { name: "Add project" }).first()
40
+ await expectAppVisible(add)
41
+ await add.click()
42
+ await expect(rows(page)).toHaveCount(5)
43
+ return page.getByRole("textbox").last()
44
+ }
45
+
46
+ test("searches every recent project, not just the five most recent", async ({ page }) => {
47
+ const search = await openProjectDialog(page)
48
+ await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
49
+
50
+ await search.fill("foxtrot")
51
+
52
+ await expect(row(page, OUTSIDE_CAP)).toHaveCount(1)
53
+ })
54
+
55
+ test("still caps the idle recent list at five projects", async ({ page }) => {
56
+ await openProjectDialog(page)
57
+
58
+ await expect(row(page, NAMES[4])).toHaveCount(1)
59
+ await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
60
+ })
packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import { base64Encode } from "@opencode-ai/core/util/encode"
3
+ import { mockOpenCodeServer } from "../utils/mock-server"
4
+ import { expectAppVisible } from "../utils/waits"
5
+
6
+ const directory = "C:/OpenCode/PromptInputV2Editing"
7
+ const projectID = "proj_prompt_input_v2_editing"
8
+ const sessionID = "ses_prompt_input_v2_editing"
9
+
10
+ test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
11
+ await mockOpenCodeServer(page, {
12
+ directory,
13
+ project: {
14
+ id: projectID,
15
+ worktree: directory,
16
+ vcs: "git",
17
+ name: "prompt-input-v2-editing",
18
+ time: { created: 1700000000000, updated: 1700000000000 },
19
+ sandboxes: [],
20
+ },
21
+ provider: { all: [], connected: [], default: {} },
22
+ sessions: [
23
+ {
24
+ id: sessionID,
25
+ slug: "prompt-input-v2-editing",
26
+ projectID,
27
+ directory,
28
+ title: "Prompt input V2 editing",
29
+ version: "dev",
30
+ time: { created: 1700000000000, updated: 1700000000000 },
31
+ },
32
+ ],
33
+ pageMessages: () => ({ items: [] }),
34
+ })
35
+ await page.addInitScript(() => {
36
+ localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
37
+ })
38
+
39
+ await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
40
+ const composer = page.locator('[data-component="prompt-input-v2"]')
41
+ const input = composer.locator('[data-component="prompt-input"]')
42
+ await expectAppVisible(composer)
43
+
44
+ await input.fill("keep me")
45
+ await composer.getByRole("button", { name: "Add images and files" }).click()
46
+ await page.getByRole("menuitem", { name: "Commands" }).click()
47
+ await page.locator('[data-suggestion-id="model.choose"]').click()
48
+
49
+ await expect(input).toHaveText("keep me")
50
+ })
packages/app/e2e/regression/prompt-thinking-level.spec.ts ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test, type Page } from "@playwright/test"
2
+ import { base64Encode } from "@opencode-ai/core/util/encode"
3
+ import { mockOpenCodeServer } from "../utils/mock-server"
4
+ import { expectAppVisible } from "../utils/waits"
5
+
6
+ const directory = "C:/OpenCode/PromptThinkingLevelRegression"
7
+ const projectID = "proj_prompt_thinking_level_regression"
8
+ const sessionID = "ses_prompt_thinking_level_regression"
9
+
10
+ test("shows the V2 thinking level control while relevant", async ({ page }) => {
11
+ await mockOpenCodeServer(page, {
12
+ directory,
13
+ project: {
14
+ id: projectID,
15
+ worktree: directory,
16
+ vcs: "git",
17
+ name: "prompt-thinking-level-regression",
18
+ time: { created: 1700000000000, updated: 1700000000000 },
19
+ sandboxes: [],
20
+ },
21
+ provider: {
22
+ all: [
23
+ {
24
+ id: "opencode",
25
+ name: "OpenCode",
26
+ models: {
27
+ "thinking-model": {
28
+ id: "thinking-model",
29
+ name: "Thinking Model",
30
+ limit: { context: 200_000 },
31
+ variants: { high: {} },
32
+ },
33
+ },
34
+ },
35
+ ],
36
+ connected: ["opencode"],
37
+ default: { providerID: "opencode", modelID: "thinking-model" },
38
+ },
39
+ sessions: [
40
+ {
41
+ id: sessionID,
42
+ slug: "prompt-thinking-level-regression",
43
+ projectID,
44
+ directory,
45
+ title: "Prompt thinking level regression",
46
+ version: "dev",
47
+ time: { created: 1700000000000, updated: 1700000000000 },
48
+ },
49
+ ],
50
+ pageMessages: () => ({ items: [] }),
51
+ })
52
+ await page.addInitScript(() => {
53
+ localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
54
+ })
55
+
56
+ await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
57
+ const composer = page.locator('[data-component="prompt-input-v2"]')
58
+ const input = composer.locator('[data-component="prompt-input"]')
59
+ const control = composer.getByRole("button", { name: "Choose model variant" })
60
+ await expectAppVisible(composer)
61
+
62
+ await idleComposer(page)
63
+ await expect(control).toBeVisible()
64
+
65
+ await control.click()
66
+ const high = page.getByRole("menuitemradio", { name: "high" })
67
+ await expect(high).toBeVisible()
68
+ await page.mouse.move(0, 0)
69
+ await expect(control).toBeVisible()
70
+ await expect(high).toBeVisible()
71
+ await high.click()
72
+
73
+ await idleComposer(page)
74
+ await input.focus()
75
+ await expect(control).toBeVisible()
76
+
77
+ await idleComposer(page)
78
+ await expect(control).toBeVisible()
79
+ })
80
+
81
+ async function idleComposer(page: Page) {
82
+ await page.mouse.move(0, 0)
83
+ await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
84
+ }
packages/app/e2e/regression/remote-session-settings.spec.ts ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { base64Encode } from "@opencode-ai/core/util/encode"
2
+ import { expect, test, type Page, type Route } from "@playwright/test"
3
+ import { installSseTransport } from "../utils/sse-transport"
4
+ import { currentSession } from "../utils/mock-server"
5
+
6
+ const serverA = "http://127.0.0.1:4096"
7
+ const serverB = "http://127.0.0.1:4097"
8
+ const directoryA = "C:/server-a"
9
+ const directoryB = "/home/server-b"
10
+ const sessionA = session("ses_server_a", directoryA, "Server A session")
11
+ const childSessionA = { ...session("ses_server_a_child", directoryA, "Server A child session"), parentID: sessionA.id }
12
+ const sessionB = session("ses_server_b", directoryB, "Server B session")
13
+
14
+ test("session settings use the remote server context", async ({ page }) => {
15
+ const permissionRequests: string[] = []
16
+ await mockServers(page, permissionRequests)
17
+ await configureServers(page)
18
+
19
+ await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
20
+ await expect(page.getByText(sessionB.title).first()).toBeVisible()
21
+ await page.keyboard.press("Control+,")
22
+
23
+ const dialog = page.locator(".settings-v2-dialog")
24
+ const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
25
+ const input = autoAccept.getByRole("switch")
26
+ await expect(autoAccept).toBeVisible()
27
+ await expect(input).toBeEnabled()
28
+ permissionRequests.length = 0
29
+ await autoAccept.locator('[data-slot="switch-control"]').click()
30
+ await expect(input).toBeChecked()
31
+ await expect
32
+ .poll(() =>
33
+ permissionRequests.some((request) => {
34
+ const url = new URL(request)
35
+ return url.origin === serverB && url.searchParams.get("directory") === directoryB
36
+ }),
37
+ )
38
+ .toBe(true)
39
+ expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true)
40
+
41
+ await dialog.getByRole("tab", { name: "Models" }).click()
42
+ await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
43
+ await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
44
+ })
45
+
46
+ test("auto-accept responds for an unfocused server session", async ({ page }) => {
47
+ const permissionRequests: string[] = []
48
+ const permissionResponses: PermissionResponse[] = []
49
+ const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
50
+ server: serverA,
51
+ retry: 20,
52
+ })
53
+ await mockServers(page, permissionRequests, permissionResponses)
54
+ await configureServers(page, [
55
+ { type: "session", server: serverA, sessionId: sessionA.id },
56
+ { type: "session", server: serverB, sessionId: sessionB.id },
57
+ ])
58
+
59
+ const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
60
+ await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
61
+ await expect(page.getByText(sessionA.title).first()).toBeVisible()
62
+ await page.keyboard.press("Control+,")
63
+ const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
64
+ await autoAccept.locator('[data-slot="switch-control"]').click()
65
+ await expect(autoAccept.getByRole("switch")).toBeChecked()
66
+ await expect
67
+ .poll(() =>
68
+ permissionRequests.some((request) => {
69
+ const url = new URL(request)
70
+ return url.origin === serverA && url.searchParams.get("directory") === directoryA
71
+ }),
72
+ )
73
+ .toBe(true)
74
+ await page.keyboard.press("Escape")
75
+
76
+ await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click()
77
+ await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
78
+ await expect(page.getByText(sessionB.title).first()).toBeVisible()
79
+ await transport.waitForConnection()
80
+
81
+ await transport.send({
82
+ directory: directoryA,
83
+ payload: {
84
+ id: "event-permission-background-a",
85
+ type: "permission.asked",
86
+ properties: {
87
+ id: "permission-background-a",
88
+ sessionID: sessionA.id,
89
+ permission: "bash",
90
+ patterns: ["git status"],
91
+ metadata: {},
92
+ always: [],
93
+ },
94
+ },
95
+ })
96
+
97
+ await expect
98
+ .poll(() => permissionResponses)
99
+ .toEqual([
100
+ {
101
+ origin: serverA,
102
+ directory: directoryA,
103
+ sessionID: sessionA.id,
104
+ permissionID: "permission-background-a",
105
+ body: { response: "once" },
106
+ },
107
+ ])
108
+
109
+ await transport.send({
110
+ directory: directoryA,
111
+ payload: {
112
+ id: "event-permission-background-a-child",
113
+ type: "permission.asked",
114
+ properties: {
115
+ id: "permission-background-a-child",
116
+ sessionID: childSessionA.id,
117
+ permission: "bash",
118
+ patterns: ["git diff"],
119
+ metadata: {},
120
+ always: [],
121
+ },
122
+ },
123
+ })
124
+
125
+ await expect
126
+ .poll(() => permissionResponses)
127
+ .toEqual([
128
+ {
129
+ origin: serverA,
130
+ directory: directoryA,
131
+ sessionID: sessionA.id,
132
+ permissionID: "permission-background-a",
133
+ body: { response: "once" },
134
+ },
135
+ {
136
+ origin: serverA,
137
+ directory: directoryA,
138
+ sessionID: childSessionA.id,
139
+ permissionID: "permission-background-a-child",
140
+ body: { response: "once" },
141
+ },
142
+ ])
143
+ })
144
+
145
+ type PermissionResponse = {
146
+ origin: string
147
+ directory?: string
148
+ sessionID: string
149
+ permissionID: string
150
+ body: unknown
151
+ }
152
+
153
+ async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
154
+ await page.addInitScript(
155
+ ({ serverB, tabs }) => {
156
+ localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
157
+ localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
158
+ localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify(tabs))
159
+ },
160
+ { serverB, tabs },
161
+ )
162
+ }
163
+
164
+ async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) {
165
+ await page.route("**/*", async (route) => {
166
+ const url = new URL(route.request().url())
167
+ if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
168
+ const remote = url.origin === serverB
169
+ const directory = remote ? directoryB : directoryA
170
+ const sessions = remote ? [sessionB] : [sessionA, childSessionA]
171
+ const requestDirectory = url.searchParams.get("directory")
172
+ const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/)
173
+ if (route.request().method() === "POST" && response) {
174
+ permissionResponses.push({
175
+ origin: url.origin,
176
+ directory: requestDirectory ?? undefined,
177
+ sessionID: response[1]!,
178
+ permissionID: response[2]!,
179
+ body: route.request().postDataJSON(),
180
+ })
181
+ return json(route, true)
182
+ }
183
+ if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
184
+ if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
185
+ return sse(route)
186
+ if (url.pathname === "/global/health") return json(route, { healthy: true })
187
+ if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent")
188
+ return json(route, { data: [] })
189
+ if (url.pathname === "/api/model/default") return json(route, { data: null })
190
+ if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname))
191
+ return json(route, { location: { directory }, data: [] })
192
+ if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] })
193
+ if (url.pathname === "/api/mcp/resource")
194
+ return json(route, { location: { directory }, data: { resources: [], templates: [] } })
195
+ if (url.pathname === "/api/project") {
196
+ return json(route, [
197
+ {
198
+ id: remote ? sessionB.projectID : "project-server-a",
199
+ worktree: directory,
200
+ vcs: "git",
201
+ time: { created: 1, updated: 1 },
202
+ sandboxes: [],
203
+ },
204
+ ])
205
+ }
206
+ if (url.pathname === "/api/project/current")
207
+ return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
208
+ if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
209
+ if (url.pathname === "/api/session/active") return json(route, { data: {} })
210
+ const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
211
+ if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
212
+ if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
213
+ return json(route, { data: [], cursor: {} })
214
+ const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
215
+ if (current) return json(route, current)
216
+ if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
217
+ if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
218
+ if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
219
+ if (url.pathname === "/permission") {
220
+ permissionRequests.push(url.toString())
221
+ return json(route, [])
222
+ }
223
+ if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
224
+ return json(route, [])
225
+ if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
226
+ if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a"))
227
+ if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
228
+ if (url.pathname === "/project" || url.pathname === "/project/current") {
229
+ const project = {
230
+ id: remote ? sessionB.projectID : "project-server-a",
231
+ worktree: directory,
232
+ vcs: "git",
233
+ time: { created: 1, updated: 1 },
234
+ sandboxes: [],
235
+ }
236
+ return json(route, url.pathname === "/project" ? [project] : project)
237
+ }
238
+ if (url.pathname === "/path")
239
+ return json(route, {
240
+ state: directory,
241
+ config: directory,
242
+ worktree: directory,
243
+ directory,
244
+ home: directory,
245
+ })
246
+ if (url.pathname === "/api/path")
247
+ return json(route, { state: directory, config: directory, worktree: directory, directory, home: directory })
248
+ if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
249
+ if (url.pathname === "/api/vcs")
250
+ return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
251
+ if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
252
+ return json(route, {})
253
+ })
254
+ }
255
+
256
+ function session(id: string, directory: string, title: string) {
257
+ return {
258
+ id,
259
+ slug: id,
260
+ projectID: `project-${id}`,
261
+ directory,
262
+ title,
263
+ version: "dev",
264
+ time: { created: 1, updated: 1 },
265
+ }
266
+ }
267
+
268
+ function provider(id: string) {
269
+ const name = id === "server-b" ? "Server B" : "Server A"
270
+ return {
271
+ all: [
272
+ {
273
+ id,
274
+ name: `${name} Provider`,
275
+ models: {
276
+ [id]: {
277
+ id,
278
+ name: `${name} Model`,
279
+ family: id,
280
+ release_date: "2026-01-01",
281
+ limit: { context: 200_000 },
282
+ },
283
+ },
284
+ },
285
+ ],
286
+ connected: [id],
287
+ default: { providerID: id, modelID: id },
288
+ }
289
+ }
290
+
291
+ function json(route: Route, body: unknown, status = 200) {
292
+ return route.fulfill({
293
+ status,
294
+ contentType: "application/json",
295
+ headers: { "access-control-allow-origin": "*" },
296
+ body: JSON.stringify(body),
297
+ })
298
+ }
299
+
300
+ function sse(route: Route) {
301
+ return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
302
+ }
packages/app/e2e/regression/remote-tab-busy.spec.ts ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test, type Page, type Route } from "@playwright/test"
2
+ import { base64Encode } from "@opencode-ai/core/util/encode"
3
+ import { currentSession } from "../utils/mock-server"
4
+
5
+ const serverA = "http://127.0.0.1:4096"
6
+ const serverB = "http://127.0.0.1:4097"
7
+ const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
8
+ const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
9
+
10
+ test("tab busy indicator reflects the tab server's own session status", async ({ page }) => {
11
+ await mockServers(page)
12
+ await page.addInitScript(
13
+ ({ serverA, serverB, sessionA, sessionB }) => {
14
+ localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
15
+ localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
16
+ localStorage.setItem(
17
+ "opencode.window.browser.dat:tabs",
18
+ JSON.stringify([
19
+ { type: "session", server: serverA, sessionId: sessionA },
20
+ { type: "session", server: serverB, sessionId: sessionB },
21
+ ]),
22
+ )
23
+ },
24
+ { serverA, serverB, sessionA: sessionA.id, sessionB: sessionB.id },
25
+ )
26
+
27
+ const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
28
+ const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
29
+ await page.goto(hrefA)
30
+ await expect(page.getByText(sessionA.title).first()).toBeVisible()
31
+
32
+ // Session B is busy on server B while server A stays the active server, so the
33
+ // busy indicator must come from the tab server's status, not the active server's.
34
+ const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
35
+ await expect(tabB.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
36
+
37
+ const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
38
+ await expect(tabA.locator("[data-titlebar-tab-title]")).toHaveText(sessionA.title)
39
+ await expect(tabA.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0)
40
+ })
41
+
42
+ function session(id: string, directory: string, title: string) {
43
+ return {
44
+ id,
45
+ slug: id,
46
+ projectID: `project-${id}`,
47
+ directory,
48
+ title,
49
+ version: "dev",
50
+ time: { created: 1, updated: 1 },
51
+ }
52
+ }
53
+
54
+ async function mockServers(page: Page) {
55
+ await page.route("**/*", async (route) => {
56
+ const url = new URL(route.request().url())
57
+ if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
58
+ const current = url.origin === serverA ? sessionA : sessionB
59
+ const directory = url.searchParams.get("directory")
60
+ if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
61
+ if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
62
+ return sse(route, url.pathname === "/api/event")
63
+ if (url.pathname === "/global/health") return json(route, {}, 404)
64
+ if (url.pathname === "/api/health") return json(route, { pid: 1 })
65
+ if (url.pathname === "/api/session/active")
66
+ return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
67
+ if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
68
+ if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
69
+ if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
70
+ if (url.pathname === `/session/${current.id}`) return json(route, current)
71
+ if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
72
+ if (url.pathname === `/session/${current.id}/message`) return json(route, [])
73
+ if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
74
+ if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
75
+ return json(route, [])
76
+ if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
77
+ if (url.pathname === "/provider")
78
+ return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
79
+ if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
80
+ if (url.pathname === "/project" || url.pathname === "/project/current") {
81
+ const project = {
82
+ id: current.projectID,
83
+ worktree: current.directory,
84
+ vcs: "git",
85
+ time: { created: 1, updated: 1 },
86
+ sandboxes: [],
87
+ }
88
+ return json(route, url.pathname === "/project" ? [project] : project)
89
+ }
90
+ if (url.pathname === "/path")
91
+ return json(route, {
92
+ state: current.directory,
93
+ config: current.directory,
94
+ worktree: current.directory,
95
+ directory: current.directory,
96
+ home: current.directory,
97
+ })
98
+ if (url.pathname === "/api/path")
99
+ return json(route, {
100
+ state: current.directory,
101
+ config: current.directory,
102
+ worktree: current.directory,
103
+ directory: current.directory,
104
+ home: current.directory,
105
+ })
106
+ if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
107
+ if (url.pathname === "/api/vcs")
108
+ return json(route, {
109
+ location: { directory: current.directory },
110
+ data: { branch: "main", defaultBranch: "main" },
111
+ })
112
+ return json(route, {})
113
+ })
114
+ }
115
+
116
+ function json(route: Route, body: unknown, status = 200) {
117
+ return route.fulfill({
118
+ status,
119
+ contentType: "application/json",
120
+ headers: { "access-control-allow-origin": "*" },
121
+ body: JSON.stringify(body),
122
+ })
123
+ }
124
+
125
+ function sse(route: Route, current: boolean) {
126
+ return route.fulfill({
127
+ status: 200,
128
+ contentType: "text/event-stream",
129
+ body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n",
130
+ })
131
+ }
packages/app/e2e/regression/review-image-flash.spec.ts ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test, type Page } from "@playwright/test"
2
+ import { base64Encode } from "@opencode-ai/core/util/encode"
3
+ import { mockOpenCodeServer } from "../utils/mock-server"
4
+ import { expectAppVisible, expectSessionTitle } from "../utils/waits"
5
+
6
+ const directory = "C:/OpenCode/ReviewImageFlashRegression"
7
+ const sessionID = "ses_review_image_flash_regression"
8
+ const title = "Review image flash regression"
9
+ const imageFile = "assets/preview.png"
10
+
11
+ test("clicking an image file in the v2 review pane does not blank the panel", async ({ page }) => {
12
+ await openReview(page)
13
+ await installReviewFlashProbe(page)
14
+
15
+ await page.getByRole("button", { name: /preview\.png/ }).click()
16
+ await waitForReviewFlashProbe(page, 400)
17
+ const trace = await collectReviewFlashProbe(page)
18
+ const bad = trace.samples.filter((sample) => sample.blank || sample.blackCenter)
19
+
20
+ expect(trace.samples.length).toBeGreaterThan(0)
21
+ expect(
22
+ bad,
23
+ JSON.stringify({ bad: bad.slice(0, 8), first: trace.samples.slice(0, 8), last: trace.samples.slice(-4) }, null, 2),
24
+ ).toEqual([])
25
+ })
26
+
27
+ async function openReview(page: Page) {
28
+ await page.setViewportSize({ width: 960, height: 900 })
29
+ await page.addInitScript(() => {
30
+ localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
31
+ })
32
+ await mockOpenCodeServer(page, {
33
+ directory,
34
+ project: {
35
+ id: "proj_review_image_flash_regression",
36
+ worktree: directory,
37
+ vcs: "git",
38
+ name: "review-image-flash-regression",
39
+ time: { created: 1700000000000, updated: 1700000000000 },
40
+ sandboxes: [],
41
+ },
42
+ provider: { all: [], connected: [], default: {} },
43
+ sessions: [
44
+ {
45
+ id: sessionID,
46
+ slug: "review-image-flash-regression",
47
+ projectID: "proj_review_image_flash_regression",
48
+ directory,
49
+ title,
50
+ version: "dev",
51
+ time: { created: 1700000000000, updated: 1700000000000 },
52
+ },
53
+ ],
54
+ vcsDiff: [
55
+ {
56
+ file: "src/example.ts",
57
+ additions: 1,
58
+ deletions: 1,
59
+ status: "modified",
60
+ patch:
61
+ "diff --git a/src/example.ts b/src/example.ts\n--- a/src/example.ts\n+++ b/src/example.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n",
62
+ },
63
+ {
64
+ file: imageFile,
65
+ patch: "",
66
+ additions: 1,
67
+ deletions: 0,
68
+ status: "added",
69
+ },
70
+ ],
71
+ fileContent: async (path) => {
72
+ if (path !== imageFile) return undefined
73
+ await new Promise((resolve) => setTimeout(resolve, 250))
74
+ return {
75
+ type: "binary",
76
+ content: "iVBORw0KGgo=",
77
+ encoding: "base64",
78
+ mimeType: "image/png",
79
+ }
80
+ },
81
+ fileList: (path) => {
82
+ if (!path) {
83
+ return [
84
+ { name: "assets", path: "assets", absolute: `${directory}/assets`, type: "directory", ignored: false },
85
+ { name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false },
86
+ ]
87
+ }
88
+ if (path === "assets") {
89
+ return [
90
+ {
91
+ name: "preview.png",
92
+ path: imageFile,
93
+ absolute: `${directory}/${imageFile}`,
94
+ type: "file",
95
+ ignored: false,
96
+ },
97
+ ]
98
+ }
99
+ if (path === "src") {
100
+ return [
101
+ {
102
+ name: "example.ts",
103
+ path: "src/example.ts",
104
+ absolute: `${directory}/src/example.ts`,
105
+ type: "file",
106
+ ignored: false,
107
+ },
108
+ ]
109
+ }
110
+ return []
111
+ },
112
+ pageMessages: () => ({
113
+ items: [
114
+ {
115
+ info: {
116
+ id: "msg_review_image_flash_regression",
117
+ sessionID,
118
+ role: "user",
119
+ time: { created: 1700000000000 },
120
+ summary: { diffs: [] },
121
+ agent: "build",
122
+ model: { providerID: "opencode", modelID: "test" },
123
+ },
124
+ parts: [
125
+ {
126
+ id: "prt_review_image_flash_regression",
127
+ sessionID,
128
+ messageID: "msg_review_image_flash_regression",
129
+ type: "text",
130
+ text: "Review this change.",
131
+ },
132
+ ],
133
+ },
134
+ ],
135
+ }),
136
+ })
137
+
138
+ await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
139
+ await expectSessionTitle(page, title)
140
+ await page.getByRole("button", { name: "Toggle review" }).click()
141
+ await expectAppVisible(page.locator('#review-panel [data-component="session-review-v2"]'))
142
+ await expectAppVisible(page.getByRole("button", { name: /preview\.png/ }))
143
+ }
144
+
145
+ async function installReviewFlashProbe(page: Page) {
146
+ await page.evaluate(() => {
147
+ const samples: Array<{
148
+ observedAtMs: number
149
+ blank: boolean
150
+ blackCenter: boolean
151
+ text: string
152
+ background: string
153
+ }> = []
154
+ const startedAt = performance.now()
155
+ const sample = () => {
156
+ const panel = document.querySelector<HTMLElement>('#review-panel [data-component="session-review-v2"]')
157
+ const rect = panel?.getBoundingClientRect()
158
+ const center = rect
159
+ ? document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2)
160
+ : undefined
161
+ const background = center instanceof Element ? getComputedStyle(center).backgroundColor : ""
162
+ samples.push({
163
+ observedAtMs: performance.now() - startedAt,
164
+ blank: !panel || panel.textContent?.trim().length === 0,
165
+ blackCenter: background === "rgb(0, 0, 0)",
166
+ text: panel?.textContent?.trim().slice(0, 80) ?? "",
167
+ background,
168
+ })
169
+ if (performance.now() - startedAt < 500) requestAnimationFrame(sample)
170
+ }
171
+ document.addEventListener(
172
+ "click",
173
+ (event) => {
174
+ const target = event.target instanceof Element ? event.target : undefined
175
+ if (!target?.closest('[data-slot="file-tree-v2-row"]')) return
176
+ requestAnimationFrame(sample)
177
+ },
178
+ { capture: true, once: true },
179
+ )
180
+ ;(window as Window & { __reviewImageFlash?: { samples: typeof samples; startedAt: number } }).__reviewImageFlash = {
181
+ samples,
182
+ startedAt,
183
+ }
184
+ })
185
+ }
186
+
187
+ async function waitForReviewFlashProbe(page: Page, durationMs: number) {
188
+ await page.waitForFunction((durationMs) => {
189
+ const state = (window as Window & { __reviewImageFlash?: { samples: unknown[]; startedAt: number } })
190
+ .__reviewImageFlash
191
+ return !!state && state.samples.length > 0 && performance.now() - state.startedAt >= durationMs
192
+ }, durationMs)
193
+ }
194
+
195
+ async function collectReviewFlashProbe(page: Page) {
196
+ return page.evaluate(() => {
197
+ return (window as Window & { __reviewImageFlash?: { samples: unknown[]; startedAt: number } }).__reviewImageFlash!
198
+ }) as Promise<{
199
+ startedAt: number
200
+ samples: Array<{ observedAtMs: number; blank: boolean; blackCenter: boolean; text: string; background: string }>
201
+ }>
202
+ }
packages/app/e2e/regression/review-line-comment.spec.ts ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test, type Page } from "@playwright/test"
2
+ import { base64Encode } from "@opencode-ai/core/util/encode"
3
+ import { mockOpenCodeServer } from "../utils/mock-server"
4
+ import { expectAppVisible, expectSessionTitle } from "../utils/waits"
5
+
6
+ const directory = "C:/OpenCode/ReviewLineCommentRegression"
7
+ const sessionID = "ses_review_line_comment_regression"
8
+ const title = "Review line comment regression"
9
+
10
+ test.beforeEach(async ({ page }) => {
11
+ await openReview(page)
12
+ })
13
+
14
+ test("opens the comment editor when code is clicked", async ({ page }) => {
15
+ const review = page.locator('[data-component="session-review"]')
16
+ const line = review.getByText("export const value = 'after'", { exact: true })
17
+ await expectAppVisible(line)
18
+ await line.click()
19
+
20
+ await expect(review.getByRole("textbox")).toBeVisible()
21
+ await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
22
+ })
23
+
24
+ test("opens the comment editor when a line number is clicked", async ({ page }) => {
25
+ const review = page.locator('[data-component="session-review"]')
26
+ const lineNumber = review.locator('[data-column-number="1"]').last()
27
+ await expectAppVisible(lineNumber)
28
+ await lineNumber.click()
29
+
30
+ await expect(review.getByRole("textbox")).toBeVisible()
31
+ await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
32
+ })
33
+
34
+ test("opens the comment editor for a line number range", async ({ page }) => {
35
+ const review = page.locator('[data-component="session-review"]')
36
+ const start = review.locator('[data-column-number="1"]').last()
37
+ const end = review.locator('[data-column-number="3"]').last()
38
+ await expectAppVisible(start)
39
+ await expectAppVisible(end)
40
+
41
+ await start.dragTo(end)
42
+
43
+ await expect(review.getByRole("textbox")).toBeVisible()
44
+ await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
45
+ })
46
+
47
+ test("shows a comment button when a line number is hovered", async ({ page }) => {
48
+ const review = page.locator('[data-component="session-review"]')
49
+ const lineNumber = review.locator('[data-column-number="1"]').last()
50
+ await expectAppVisible(lineNumber)
51
+
52
+ const comment = review.getByRole("button", { name: "Comment", exact: true })
53
+ await expect(async () => {
54
+ await lineNumber.hover()
55
+ await expect(lineNumber).toHaveAttribute("data-hovered", "")
56
+ await expect(comment).toHaveCount(1)
57
+ await expect(comment).toHaveCSS("pointer-events", "auto")
58
+ await comment.focus()
59
+ await expect(comment).toBeFocused()
60
+ }).toPass({ timeout: 10_000 })
61
+ await comment.press("Enter")
62
+ await expect(review.getByRole("textbox")).toBeVisible()
63
+ await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
64
+ })
65
+
66
+ test("stages a submitted line comment in the prompt context", async ({ page }) => {
67
+ page.on("request", (request) => {
68
+ expect.soft(request.method(), `unexpected ${request.method()} ${new URL(request.url()).pathname}`).toBe("GET")
69
+ })
70
+
71
+ const review = page.locator('[data-component="session-review"]')
72
+ await review.getByText("export const value = 'after'", { exact: true }).click()
73
+ const textbox = review.getByRole("textbox")
74
+ await expect(textbox).toBeVisible()
75
+ await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
76
+ await textbox.fill("Use the existing value instead")
77
+ const submit = review.locator('[data-slot="line-comment-action"][data-variant="primary"]')
78
+ await expect(submit).toBeEnabled()
79
+ await submit.click()
80
+
81
+ await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible()
82
+ await page.getByRole("tab", { name: "Session" }).click()
83
+ const context = page.getByText("Use the existing value instead", { exact: true }).last()
84
+ await expect(context).toBeVisible()
85
+ await expect(context.locator("..")).toContainText("review.ts:2")
86
+ })
87
+
88
+ async function openReview(page: Page) {
89
+ await page.setViewportSize({ width: 700, height: 900 })
90
+ await mockOpenCodeServer(page, {
91
+ protocol: "v2",
92
+ directory,
93
+ project: {
94
+ id: "proj_review_line_comment_regression",
95
+ worktree: directory,
96
+ vcs: "git",
97
+ name: "review-line-comment-regression",
98
+ time: { created: 1700000000000, updated: 1700000000000 },
99
+ sandboxes: [],
100
+ },
101
+ provider: { all: [], connected: [], default: {} },
102
+ sessions: [
103
+ {
104
+ id: sessionID,
105
+ slug: "review-line-comment-regression",
106
+ projectID: "proj_review_line_comment_regression",
107
+ directory,
108
+ title,
109
+ version: "dev",
110
+ time: { created: 1700000000000, updated: 1700000000000 },
111
+ },
112
+ ],
113
+ vcsDiff: [
114
+ {
115
+ file: "src/review.ts",
116
+ additions: 1,
117
+ deletions: 1,
118
+ status: "modified",
119
+ patch:
120
+ "diff --git a/src/review.ts b/src/review.ts\n--- a/src/review.ts\n+++ b/src/review.ts\n@@ -1,3 +1,3 @@\n export const first = 1\n-export const value = 'before'\n+export const value = 'after'\n export const last = 3\n",
121
+ },
122
+ ],
123
+ pageMessages: () => ({
124
+ items: [
125
+ {
126
+ info: {
127
+ id: "msg_review_line_comment_regression",
128
+ sessionID,
129
+ role: "user",
130
+ time: { created: 1700000000000 },
131
+ summary: { diffs: [] },
132
+ agent: "build",
133
+ model: { providerID: "opencode", modelID: "test" },
134
+ },
135
+ parts: [
136
+ {
137
+ id: "prt_review_line_comment_regression",
138
+ sessionID,
139
+ messageID: "msg_review_line_comment_regression",
140
+ type: "text",
141
+ text: "Review this change.",
142
+ },
143
+ ],
144
+ },
145
+ ],
146
+ }),
147
+ })
148
+
149
+ await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
150
+ await expectSessionTitle(page, title)
151
+ const changes = page.getByRole("tab", { name: "Changes" })
152
+ const diffResponse = page.waitForResponse(
153
+ (response) =>
154
+ response.request().method() === "GET" && response.ok() && new URL(response.url()).pathname === "/api/vcs/diff",
155
+ )
156
+ await changes.click()
157
+ expect((await (await diffResponse).json()).data).toHaveLength(1)
158
+ await expect(page.getByRole("tab", { selected: true })).toHaveAccessibleName(/Files Changed/)
159
+
160
+ const review = page.locator('[data-component="session-review"]')
161
+ await expectAppVisible(review)
162
+ const file = review.locator('[data-file="src/review.ts"]')
163
+ await expectAppVisible(file)
164
+ const trigger = file.getByRole("button", { expanded: false })
165
+ await expect(trigger).toHaveCount(1)
166
+ await trigger.click()
167
+ await expect(file.getByRole("button", { expanded: true })).toBeVisible()
168
+ await expect(file.getByText("export const value = 'after'", { exact: true })).toBeVisible()
169
+ }
packages/app/e2e/regression/review-open-file.spec.ts ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { base64Encode } from "@opencode-ai/core/util/encode"
2
+ import { expect, test } from "@playwright/test"
3
+ import { mockOpenCodeServer } from "../utils/mock-server"
4
+ import { expectSessionTitle } from "../utils/waits"
5
+
6
+ const directory = "C:/OpenCode/ReviewOpenFile"
7
+ const projectID = "proj_review_open_file"
8
+ const sessionID = "ses_review_open_file"
9
+ const title = "Review open file"
10
+ const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
11
+
12
+ test.use({ viewport: { width: 1440, height: 900 } })
13
+
14
+ test("opens and searches project files inline", async ({ page }) => {
15
+ const searches: { query: string; dirs?: string; limit?: number }[] = []
16
+ await mockOpenCodeServer(page, {
17
+ directory,
18
+ project: {
19
+ id: projectID,
20
+ worktree: directory,
21
+ vcs: "git",
22
+ name: "open-file-project",
23
+ time: { created: 1700000000000, updated: 1700000000000 },
24
+ sandboxes: [],
25
+ },
26
+ provider: {
27
+ all: [
28
+ {
29
+ id: "opencode",
30
+ name: "OpenCode",
31
+ models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
32
+ },
33
+ ],
34
+ connected: ["opencode"],
35
+ default: { providerID: "opencode", modelID: "test" },
36
+ },
37
+ sessions: [
38
+ {
39
+ id: sessionID,
40
+ slug: sessionID,
41
+ projectID,
42
+ directory,
43
+ title,
44
+ version: "dev",
45
+ time: { created: 1700000000000, updated: 1700000000000 },
46
+ },
47
+ ],
48
+ vcsDiff: [fileDiff("src/changed.ts")],
49
+ fileList: (path) => {
50
+ if (path) return []
51
+ return [
52
+ fileNode("README.md"),
53
+ { name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false },
54
+ ]
55
+ },
56
+ fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
57
+ findFiles: (input) => {
58
+ searches.push(input)
59
+ return input.query === "nested" ? ["src/nested.ts"] : []
60
+ },
61
+ pageMessages: () => ({ items: [] }),
62
+ })
63
+ await page.addInitScript(
64
+ ({ directory, server, sessionID }) => {
65
+ localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
66
+ localStorage.setItem(
67
+ "opencode.global.dat:server",
68
+ JSON.stringify({
69
+ projects: { local: [{ worktree: directory, expanded: true }] },
70
+ lastProject: { local: directory },
71
+ }),
72
+ )
73
+ localStorage.setItem(
74
+ "opencode.global.dat:layout",
75
+ JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
76
+ )
77
+ localStorage.setItem(
78
+ "opencode.global.dat:review-panel-v2",
79
+ JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }),
80
+ )
81
+ localStorage.setItem(
82
+ "opencode.window.browser.dat:tabs",
83
+ JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
84
+ )
85
+ },
86
+ { directory, server, sessionID },
87
+ )
88
+
89
+ await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
90
+ await expectSessionTitle(page, title)
91
+
92
+ const panel = page.locator("#review-panel")
93
+ const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
94
+ const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
95
+ const contextButton = page.getByRole("button", { name: "View context usage" })
96
+ await contextButton.click()
97
+ await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
98
+ await panel.getByRole("button", { name: "Open file" }).click()
99
+ await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
100
+ await expect(sidebarToggle).toBeDisabled()
101
+ await expect(sidebar).toBeVisible()
102
+ await contextButton.click()
103
+ await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
104
+ await expect(sidebar).toBeHidden()
105
+ await panel.getByRole("button", { name: "Open file" }).click()
106
+ const filter = panel.getByRole("combobox", { name: "Filter files" })
107
+ await expect(filter).toBeFocused()
108
+ await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
109
+ await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible()
110
+
111
+ await panel.getByRole("button", { name: "README.md" }).click()
112
+ await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
113
+ await expect(sidebarToggle).toBeEnabled()
114
+ await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
115
+ await expect(sidebar).toHaveCount(0)
116
+
117
+ await panel.getByRole("button", { name: "Open file" }).click()
118
+ await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
119
+ await expect(sidebar).toBeVisible()
120
+ await filter.fill("nested")
121
+ const result = panel.getByRole("option", { name: /nested\.ts/ })
122
+ await expect(result).toBeVisible()
123
+ const resultID = await result.getAttribute("id")
124
+ expect(resultID).toBeTruthy()
125
+ await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
126
+ await filter.press("Enter")
127
+ await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
128
+ await expect(sidebarToggle).toBeEnabled()
129
+ await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
130
+ expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
131
+
132
+ await panel.getByRole("button", { name: "Open file" }).click()
133
+ await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
134
+ await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
135
+ await expect(sidebarToggle).toBeDisabled()
136
+ await panel.locator("#session-side-panel-review-tab").click()
137
+ await expect(sidebarToggle).toBeEnabled()
138
+ await panel.getByRole("tab", { name: "Open file" }).click()
139
+ await page.keyboard.press("Control+w")
140
+ await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
141
+ await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
142
+ })
143
+
144
+ function fileNode(path: string) {
145
+ return {
146
+ name: path,
147
+ path,
148
+ absolute: `${directory}/${path}`,
149
+ type: "file",
150
+ ignored: false,
151
+ }
152
+ }
153
+
154
+ function fileDiff(file: string) {
155
+ return {
156
+ file,
157
+ before: "before\n",
158
+ after: "after\n",
159
+ additions: 1,
160
+ deletions: 1,
161
+ status: "modified",
162
+ }
163
+ }
packages/app/e2e/regression/review-state-persistence.spec.ts ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { base64Encode } from "@opencode-ai/core/util/encode"
2
+ import { expect, test, type Page } from "@playwright/test"
3
+ import { mockOpenCodeServer } from "../utils/mock-server"
4
+ import { expectSessionTitle } from "../utils/waits"
5
+
6
+ const directory = "C:/OpenCode/ReviewStatePersistence"
7
+ const projectID = "proj_review_state_persistence"
8
+ const sessionA = "ses_review_state_a"
9
+ const sessionB = "ses_review_state_b"
10
+ const titleA = "Alpha review state"
11
+ const titleB = "Beta review state"
12
+ const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
13
+
14
+ test.use({ viewport: { width: 1440, height: 900 } })
15
+
16
+ test("restores review mode and selected file per session", async ({ page }) => {
17
+ await setup(page)
18
+ await page.goto(sessionHref(sessionA))
19
+ await expectSessionTitle(page, titleA)
20
+ await page.getByRole("button", { name: "Toggle review" }).click()
21
+
22
+ await selectMode(page, "Git changes", "Branch changes")
23
+ await selectFile(page, "beta.ts")
24
+
25
+ await switchSession(page, titleB)
26
+ await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
27
+ await selectFile(page, "gamma.ts")
28
+
29
+ await switchSession(page, titleA)
30
+ await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
31
+ await expectSelectedFile(page, "beta.ts")
32
+ await selectMode(page, "Branch changes", "Git changes")
33
+ await expectSelectedFile(page, "alpha.ts")
34
+ await selectMode(page, "Git changes", "Branch changes")
35
+ await expectSelectedFile(page, "beta.ts")
36
+
37
+ await page.reload()
38
+ await expectSessionTitle(page, titleA)
39
+ await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
40
+ await expectSelectedFile(page, "beta.ts")
41
+
42
+ await switchSession(page, titleB)
43
+ await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
44
+ await expectSelectedFile(page, "gamma.ts")
45
+ })
46
+
47
+ async function selectMode(page: Page, current: string, next: string) {
48
+ await page.getByRole("button", { name: current }).click()
49
+ await page.getByRole("option", { name: next }).dispatchEvent("click")
50
+ }
51
+
52
+ async function selectFile(page: Page, file: string) {
53
+ await page.getByRole("button", { name: file }).click()
54
+ await expectSelectedFile(page, file)
55
+ }
56
+
57
+ async function expectSelectedFile(page: Page, file: string) {
58
+ await expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText(file)
59
+ }
60
+
61
+ async function switchSession(page: Page, title: string) {
62
+ await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
63
+ await expectSessionTitle(page, title)
64
+ }
65
+
66
+ async function setup(page: Page) {
67
+ await mockOpenCodeServer(page, {
68
+ protocol: "v1",
69
+ directory,
70
+ project: {
71
+ id: projectID,
72
+ worktree: directory,
73
+ vcs: "git",
74
+ name: "review-state-persistence",
75
+ time: { created: 1700000000000, updated: 1700000000000 },
76
+ sandboxes: [],
77
+ },
78
+ provider: {
79
+ all: [
80
+ {
81
+ id: "opencode",
82
+ name: "OpenCode",
83
+ models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
84
+ },
85
+ ],
86
+ connected: ["opencode"],
87
+ default: { providerID: "opencode", modelID: "test" },
88
+ },
89
+ sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
90
+ pageMessages: () => ({ items: [] }),
91
+ })
92
+ await page.route(/\/vcs(?:\?.*)?$/, (route) =>
93
+ route.fulfill({
94
+ status: 200,
95
+ contentType: "application/json",
96
+ body: JSON.stringify({ branch: "feature", default_branch: "dev" }),
97
+ }),
98
+ )
99
+ await page.route("**/vcs/diff**", (route) =>
100
+ route.fulfill({
101
+ status: 200,
102
+ contentType: "application/json",
103
+ body: JSON.stringify(
104
+ new URL(route.request().url()).searchParams.get("mode") === "branch"
105
+ ? [diff("src/alpha.ts"), diff("src/beta.ts")]
106
+ : [diff("src/alpha.ts"), diff("src/gamma.ts")],
107
+ ),
108
+ }),
109
+ )
110
+ await page.addInitScript(
111
+ ({ directory, server, sessions }) => {
112
+ localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
113
+ localStorage.setItem(
114
+ "opencode.global.dat:server",
115
+ JSON.stringify({
116
+ projects: { local: [{ worktree: directory, expanded: true }] },
117
+ lastProject: { local: directory },
118
+ }),
119
+ )
120
+ localStorage.setItem(
121
+ "opencode.window.browser.dat:tabs",
122
+ JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
123
+ )
124
+ },
125
+ { directory, server, sessions: [sessionA, sessionB] },
126
+ )
127
+ }
128
+
129
+ function session(id: string, title: string, created: number) {
130
+ return {
131
+ id,
132
+ slug: id,
133
+ projectID,
134
+ directory,
135
+ title,
136
+ version: "dev",
137
+ time: { created, updated: created },
138
+ }
139
+ }
140
+
141
+ function diff(file: string) {
142
+ return {
143
+ file,
144
+ additions: 1,
145
+ deletions: 1,
146
+ status: "modified",
147
+ patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
148
+ }
149
+ }
150
+
151
+ function sessionHref(sessionID: string) {
152
+ return `/server/${base64Encode(server)}/session/${sessionID}`
153
+ }
packages/app/e2e/regression/review-tab-switch.spec.ts ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { base64Encode } from "@opencode-ai/core/util/encode"
2
+ import { expect, test, type Page } from "@playwright/test"
3
+ import { mockOpenCodeServer } from "../utils/mock-server"
4
+ import { expectAppVisible, expectSessionTitle } from "../utils/waits"
5
+
6
+ const directory = "C:/OpenCode/ReviewTabSwitch"
7
+ const projectID = "proj_review_tab_switch"
8
+ const sessionA = "ses_review_tab_a"
9
+ const sessionB = "ses_review_tab_b"
10
+ const titleA = "Alpha session"
11
+ const titleB = "Beta session"
12
+ const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
13
+ const diffs = Array.from({ length: 2_740 }, (_, index) =>
14
+ fileDiff(`src/generated-${String(index).padStart(4, "0")}.ts`),
15
+ )
16
+ // Marks the review pane DOM node so a remount (fresh node) is detectable.
17
+ const PROBE = "original"
18
+
19
+ test.use({ viewport: { width: 1440, height: 900 } })
20
+
21
+ // The v2 review pane's diff data is workspace-scoped: switching between session
22
+ // tabs in the same workspace must update its parameters reactively instead of
23
+ // tearing the pane down and remounting it (which flickers).
24
+ test("keeps the v2 review pane mounted when switching session tabs in a workspace", async ({ page }) => {
25
+ await setup(page)
26
+
27
+ await page.goto(sessionHref(sessionA))
28
+ await expectSessionTitle(page, titleA)
29
+
30
+ await page.getByRole("button", { name: "Toggle review" }).click()
31
+ const reviewTab = page.locator("#session-side-panel-review-tab")
32
+ const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel")
33
+ await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
34
+ await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
35
+ const review = page.locator('#review-panel [data-component="session-review-v2"]')
36
+ await expectAppVisible(review)
37
+ await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
38
+ await writeProbe(page)
39
+
40
+ await switchTab(page, titleB)
41
+ await expectSessionTitle(page, titleB)
42
+ await expectAppVisible(review)
43
+ await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
44
+ expect(await readProbe(page)).toBe(PROBE)
45
+
46
+ await switchTab(page, titleA)
47
+ await expectSessionTitle(page, titleA)
48
+ await expectAppVisible(review)
49
+ await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
50
+ expect(await readProbe(page)).toBe(PROBE)
51
+
52
+ const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
53
+ await viewport.hover()
54
+ await page.mouse.wheel(0, 100_000)
55
+ await expect
56
+ .poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
57
+ .toBeLessThanOrEqual(1)
58
+ await expect(page.getByRole("button", { name: "generated-2739.ts" })).toBeVisible()
59
+ })
60
+
61
+ type Probed = HTMLElement & { __e2eProbe?: string }
62
+
63
+ async function switchTab(page: Page, title: string) {
64
+ await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
65
+ }
66
+
67
+ async function writeProbe(page: Page) {
68
+ await page.locator('#review-panel [data-component="session-review-v2"]').evaluate((el, probe) => {
69
+ ;(el as Probed).__e2eProbe = probe
70
+ }, PROBE)
71
+ }
72
+
73
+ async function readProbe(page: Page) {
74
+ return page.locator('#review-panel [data-component="session-review-v2"]').evaluate((el) => (el as Probed).__e2eProbe)
75
+ }
76
+
77
+ async function setup(page: Page) {
78
+ await mockOpenCodeServer(page, {
79
+ directory,
80
+ project: {
81
+ id: projectID,
82
+ worktree: directory,
83
+ vcs: "git",
84
+ name: "review-tab-switch",
85
+ time: { created: 1700000000000, updated: 1700000000000 },
86
+ sandboxes: [],
87
+ },
88
+ provider: {
89
+ all: [
90
+ {
91
+ id: "opencode",
92
+ name: "OpenCode",
93
+ models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
94
+ },
95
+ ],
96
+ connected: ["opencode"],
97
+ default: { providerID: "opencode", modelID: "test" },
98
+ },
99
+ sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
100
+ vcsDiff: diffs,
101
+ pageMessages: () => ({ items: [] }),
102
+ })
103
+
104
+ await page.addInitScript(
105
+ ({ directory, server, sessions }) => {
106
+ localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
107
+ localStorage.setItem(
108
+ "opencode.global.dat:server",
109
+ JSON.stringify({
110
+ projects: { local: [{ worktree: directory, expanded: true }] },
111
+ lastProject: { local: directory },
112
+ }),
113
+ )
114
+ localStorage.setItem(
115
+ "opencode.window.browser.dat:tabs",
116
+ JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
117
+ )
118
+ },
119
+ { directory, server, sessions: [sessionA, sessionB] },
120
+ )
121
+ }
122
+
123
+ function session(id: string, title: string, created: number) {
124
+ return {
125
+ id,
126
+ slug: id,
127
+ projectID,
128
+ directory,
129
+ title,
130
+ version: "dev",
131
+ time: { created, updated: created },
132
+ }
133
+ }
134
+
135
+ function sessionHref(sessionID: string) {
136
+ return `/server/${base64Encode(server)}/session/${sessionID}`
137
+ }
138
+
139
+ function fileDiff(file: string) {
140
+ return {
141
+ file,
142
+ additions: 1,
143
+ deletions: 1,
144
+ status: "modified",
145
+ patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
146
+ }
147
+ }
packages/app/e2e/regression/review-terminal-stacked.spec.ts ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test, type Page } from "@playwright/test"
2
+ import { mockOpenCodeServer } from "../utils/mock-server"
3
+ import { expectSessionTitle } from "../utils/waits"
4
+
5
+ const directory = "C:/OpenCode/ReviewTerminalStacked"
6
+ const projectID = "proj_review_terminal_stacked"
7
+ const sessionID = "ses_review_terminal_stacked"
8
+ const title = "Review terminal stacked"
9
+ const branchDiffs = [
10
+ fileDiff(".github/actions/setup-bun/action.yml", 7),
11
+ ...Array.from({ length: 2_739 }, (_, index) =>
12
+ fileDiff(
13
+ `src/branch/d${String(Math.floor(index / 100)).padStart(5, "0")}/generated-${String(index).padStart(4, "0")}.ts`,
14
+ 100,
15
+ false,
16
+ ),
17
+ ),
18
+ ]
19
+
20
+ test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
21
+ test.setTimeout(120_000)
22
+ const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
23
+ const sessionStatus = { [sessionID]: { type: "idle" as "busy" | "idle" } }
24
+ let detailVersion = 1
25
+ let detailFailures = 1
26
+ await page.setViewportSize({ width: 1400, height: 900 })
27
+ await mockOpenCodeServer(page, {
28
+ protocol: "v1",
29
+ directory,
30
+ project: {
31
+ id: projectID,
32
+ worktree: directory,
33
+ vcs: "git",
34
+ name: "review-terminal-stacked",
35
+ time: { created: 1700000000000, updated: 1700000000000 },
36
+ sandboxes: [],
37
+ },
38
+ provider: {
39
+ all: [
40
+ {
41
+ id: "opencode",
42
+ name: "OpenCode",
43
+ models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
44
+ },
45
+ ],
46
+ connected: ["opencode"],
47
+ default: { providerID: "opencode", modelID: "test" },
48
+ },
49
+ sessions: [
50
+ {
51
+ id: sessionID,
52
+ slug: "review-terminal-stacked",
53
+ projectID,
54
+ directory,
55
+ title,
56
+ version: "dev",
57
+ time: { created: 1700000000000, updated: 1700000000000 },
58
+ },
59
+ ],
60
+ sessionStatus: () => sessionStatus,
61
+ pageMessages: () => ({ items: [] }),
62
+ events: () => events.splice(0, 1),
63
+ eventRetry: 16,
64
+ })
65
+ await page.route(/\/vcs(?:\?.*)?$/, (route) =>
66
+ route.fulfill({
67
+ status: 200,
68
+ contentType: "application/json",
69
+ body: JSON.stringify({
70
+ branch: "review-pane-performance",
71
+ default_branch: "dev",
72
+ }),
73
+ }),
74
+ )
75
+ await page.route("**/vcs/diff**", (route) => {
76
+ const url = new URL(route.request().url())
77
+ const scope = url.searchParams.get("directory")?.replaceAll("\\", "/")
78
+ const detail = scope?.endsWith("/src/branch/d00027")
79
+ if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
80
+ return route.fulfill({
81
+ status: 200,
82
+ contentType: "application/json",
83
+ body: JSON.stringify(
84
+ url.searchParams.get("mode") === "branch"
85
+ ? detail
86
+ ? branchDiffs
87
+ .filter((diff) => diff.file.startsWith("src/branch/d00027/"))
88
+ .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
89
+ : branchDiffs
90
+ : Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
91
+ ),
92
+ })
93
+ })
94
+ await page.route("**/pty*", (route) =>
95
+ route.fulfill({
96
+ status: 200,
97
+ contentType: "application/json",
98
+ body: JSON.stringify({
99
+ location: { directory, project: { id: projectID, directory } },
100
+ data: {
101
+ id: "pty_review_terminal",
102
+ title: "Terminal 1",
103
+ command: "cmd.exe",
104
+ args: [],
105
+ cwd: directory,
106
+ status: "running",
107
+ pid: 1,
108
+ },
109
+ }),
110
+ }),
111
+ )
112
+ await page.route("**/pty/pty_review_terminal*", (route) =>
113
+ route.fulfill({
114
+ status: 200,
115
+ contentType: "application/json",
116
+ body: JSON.stringify({
117
+ location: { directory, project: { id: projectID, directory } },
118
+ data: {
119
+ id: "pty_review_terminal",
120
+ title: "Terminal 1",
121
+ command: "cmd.exe",
122
+ args: [],
123
+ cwd: directory,
124
+ status: "running",
125
+ pid: 1,
126
+ },
127
+ }),
128
+ }),
129
+ )
130
+ await page.route("**/pty/pty_review_terminal/connect-token*", (route) =>
131
+ route.fulfill({
132
+ status: 200,
133
+ contentType: "application/json",
134
+ body: JSON.stringify({
135
+ location: { directory, project: { id: projectID, directory } },
136
+ data: { ticket: "e2e-ticket", expires_in: 60 },
137
+ }),
138
+ }),
139
+ )
140
+ await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
141
+ await page.addInitScript(() => {
142
+ localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
143
+ localStorage.setItem(
144
+ "opencode.global.dat:layout",
145
+ JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
146
+ )
147
+ })
148
+
149
+ await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
150
+ await expectSessionTitle(page, title)
151
+ await expect(page.locator("#review-panel")).toBeVisible()
152
+ await expectTree(page, 8, "git-0.ts")
153
+
154
+ await selectMode(page, "Git changes", "Branch changes")
155
+ await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740")
156
+ await page.keyboard.press("Control+Backquote")
157
+ await expect(page.locator("#terminal-panel")).toBeVisible()
158
+ await expectTree(page, 2_773, "action.yml")
159
+ await expectStackGeometry(page)
160
+
161
+ const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
162
+ await treeViewport.hover()
163
+ await page.mouse.wheel(0, 100_000)
164
+ await expect
165
+ .poll(() => treeViewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
166
+ .toBeLessThanOrEqual(1)
167
+ const lastFile = page.getByRole("button", { name: "generated-2738.ts" })
168
+ await expect(lastFile).toBeVisible()
169
+ const bottomGap = await lastFile.evaluate((element) => {
170
+ const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
171
+ return viewport.bottom - element.getBoundingClientRect().bottom
172
+ })
173
+ expect(bottomGap).toBeGreaterThanOrEqual(0)
174
+ expect(bottomGap).toBeLessThanOrEqual(16)
175
+ const lazyDiff = page.waitForRequest((request) => {
176
+ const url = new URL(request.url())
177
+ return (
178
+ url.pathname === "/vcs/diff" &&
179
+ url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
180
+ )
181
+ })
182
+ await lastFile.click()
183
+ await lazyDiff
184
+ const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
185
+ await expect(preview).toContainText("after-1")
186
+ detailVersion = 2
187
+ sessionStatus[sessionID] = { type: "busy" }
188
+ events.push(statusEvent("busy"))
189
+ await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
190
+ const refreshedDiff = page.waitForRequest((request) => {
191
+ const url = new URL(request.url())
192
+ return (
193
+ url.pathname === "/vcs/diff" &&
194
+ url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
195
+ )
196
+ })
197
+ sessionStatus[sessionID] = { type: "idle" }
198
+ events.push(statusEvent("idle"))
199
+ await refreshedDiff
200
+ await expect(preview).toContainText("after-2")
201
+ await selectMode(page, "Branch changes", "Git changes")
202
+ await expectTree(page, 8, "git-0.ts")
203
+ await page.getByRole("button", { name: "git-0.ts" }).click()
204
+ await selectMode(page, "Git changes", "Branch changes")
205
+ await expectTree(page, 2_773, "action.yml")
206
+
207
+ const filter = page.getByRole("searchbox", { name: "Filter files" })
208
+ await filter.fill("generated-2738")
209
+ await expectTree(page, 1, "generated-2738.ts")
210
+ await filter.fill("")
211
+ await expectTree(page, 2_773, "action.yml")
212
+
213
+ await page.getByRole("button", { name: "Toggle file tree" }).click()
214
+ await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0)
215
+ await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0)
216
+ await page.getByRole("button", { name: "Toggle file tree" }).click()
217
+ await expectTree(page, 2_773, "action.yml")
218
+
219
+ await page.keyboard.press("Control+Backquote")
220
+ await expect(page.locator("#terminal-panel")).toHaveCount(0)
221
+ await expectTree(page, 2_773, "action.yml")
222
+ await page.keyboard.press("Control+Backquote")
223
+ await expect(page.locator("#terminal-panel")).toBeVisible()
224
+ await expectTree(page, 2_773, "action.yml")
225
+
226
+ await page.getByRole("button", { name: "Toggle review" }).click()
227
+ await expect(page.locator("#review-panel")).toHaveCount(0)
228
+ await page.getByRole("button", { name: "Toggle review" }).click()
229
+ await expectTree(page, 2_773, "action.yml")
230
+ await page.setViewportSize({ width: 1_000, height: 700 })
231
+ await expectTree(page, 2_773, "action.yml")
232
+ await expectStackGeometry(page)
233
+ await page.setViewportSize({ width: 1_000, height: 120 })
234
+ await page.setViewportSize({ width: 1_400, height: 900 })
235
+ await expectTree(page, 2_773, "action.yml")
236
+ await expectStackGeometry(page)
237
+ })
238
+
239
+ async function selectMode(page: Page, current: string, next: string) {
240
+ await page.getByRole("button", { name: current }).click()
241
+ const option = page.getByRole("option", { name: next })
242
+ await expect(option).toBeVisible()
243
+ await option.click()
244
+ }
245
+
246
+ async function expectTree(page: Page, total: number, file: string) {
247
+ await expectMountedTree(page, total)
248
+ await expect(page.getByRole("button", { name: file })).toBeVisible()
249
+ }
250
+
251
+ async function expectMountedTree(page: Page, total: number) {
252
+ const tree = page.locator('#review-panel [data-component="file-tree-v2"]')
253
+ await expect(tree).toHaveAttribute("data-total-rows", String(total))
254
+ await expect
255
+ .poll(() => tree.evaluate((element) => element.querySelectorAll('[data-slot="file-tree-v2-row"]').length))
256
+ .toBeGreaterThan(0)
257
+ const state = await tree.evaluate((element) => ({
258
+ root: element.getBoundingClientRect().height,
259
+ viewport: element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect().height,
260
+ rows: element.querySelectorAll('[data-slot="file-tree-v2-row"]').length,
261
+ }))
262
+ expect(state.viewport).toBeGreaterThan(0)
263
+ expect(state.root).toBeGreaterThan(0)
264
+ expect(state.rows).toBeGreaterThan(0)
265
+ expect(state.rows).toBeLessThanOrEqual(60)
266
+ }
267
+
268
+ async function expectStackGeometry(page: Page) {
269
+ const geometry = await page.evaluate(() => {
270
+ const review = document.querySelector<HTMLElement>("#review-panel")!
271
+ const terminal = document.querySelector<HTMLElement>("#terminal-panel")!
272
+ const reviewParent = review.parentElement!.getBoundingClientRect()
273
+ const terminalParent = terminal.parentElement!.getBoundingClientRect()
274
+ return {
275
+ review: review.getBoundingClientRect().height,
276
+ reviewParent: reviewParent.height,
277
+ terminal: terminal.getBoundingClientRect().height,
278
+ terminalParent: terminalParent.height,
279
+ }
280
+ })
281
+ expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1)
282
+ expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1)
283
+ }
284
+
285
+ function base64Encode(value: string) {
286
+ return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
287
+ }
288
+
289
+ function statusEvent(type: "busy" | "idle") {
290
+ return {
291
+ directory,
292
+ payload: { type: "session.status", properties: { sessionID, status: { type } } },
293
+ }
294
+ }
295
+
296
+ function fileDiff(file: string, additions: number, loaded = true, version = 1) {
297
+ return {
298
+ file,
299
+ additions,
300
+ deletions: 0,
301
+ status: "modified",
302
+ patch: loaded
303
+ ? `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after-${version}'\n`
304
+ : `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}`,
305
+ }
306
+ }
packages/app/e2e/regression/session-list-path-loading.spec.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test } from "@playwright/test"
2
+ import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
3
+ import { mockOpenCodeServer } from "../utils/mock-server"
4
+ import { expectAppVisible } from "../utils/waits"
5
+
6
+ test("shows loaded sessions before the directory path request resolves", async ({ page }) => {
7
+ await mockOpenCodeServer(page, {
8
+ sessions: fixture.sessions,
9
+ provider: fixture.provider,
10
+ directory: fixture.directory,
11
+ project: fixture.project,
12
+ pageMessages,
13
+ })
14
+
15
+ let releasePath!: () => void
16
+ const pathBlocked = new Promise<void>((resolve) => {
17
+ releasePath = resolve
18
+ })
19
+ await page.route("**/api/path?*", async (route) => {
20
+ if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback()
21
+ await pathBlocked
22
+ return route.fallback()
23
+ })
24
+
25
+ await page.addInitScript((directory) => {
26
+ localStorage.setItem(
27
+ "opencode.global.dat:server",
28
+ JSON.stringify({
29
+ projects: { local: [{ worktree: directory, expanded: true }] },
30
+ lastProject: { local: directory },
31
+ }),
32
+ )
33
+ }, fixture.directory)
34
+
35
+ await page.goto("/")
36
+ try {
37
+ await expectAppVisible(page.getByText(fixture.expected.sourceTitle).first())
38
+ } finally {
39
+ releasePath()
40
+ }
41
+ })
packages/app/e2e/regression/session-rename.spec.ts ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
3
+ import { mockOpenCodeServer } from "../utils/mock-server"
4
+
5
+ test.beforeEach(async ({ page }) => {
6
+ const sessions = fixture.sessions.map((session) => ({ ...session }))
7
+ await mockOpenCodeServer(page, {
8
+ protocol: "v1",
9
+ sessions,
10
+ provider: fixture.provider,
11
+ directory: fixture.directory,
12
+ project: fixture.project,
13
+ pageMessages,
14
+ })
15
+ await page.route(/\/session\/[^/]+(?:\?.*)?$/, async (route) => {
16
+ if (route.request().method() !== "PATCH") return route.fallback()
17
+ const id = new URL(route.request().url()).pathname.split("/").at(-1)
18
+ const session = sessions.find((item) => item.id === id)
19
+ const payload: unknown = route.request().postDataJSON()
20
+ if (
21
+ !session ||
22
+ !payload ||
23
+ typeof payload !== "object" ||
24
+ !("title" in payload) ||
25
+ typeof payload.title !== "string"
26
+ )
27
+ throw new Error("Invalid rename request")
28
+ session.title = payload.title
29
+ await route.fulfill({ json: session, headers: { "access-control-allow-origin": "*" } })
30
+ })
31
+ await page.addInitScript((directory) => {
32
+ localStorage.setItem(
33
+ "opencode.global.dat:server",
34
+ JSON.stringify({
35
+ projects: { local: [{ worktree: directory, expanded: true }] },
36
+ lastProject: { local: directory },
37
+ }),
38
+ )
39
+ }, fixture.directory)
40
+ await page.goto("/")
41
+ await page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle }).click()
42
+ await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
43
+ })
44
+
45
+ for (const commit of ["Enter", "blur", "click outside"]) {
46
+ test(`saves the session heading on ${commit}`, async ({ page }) => {
47
+ await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
48
+ const input = page.locator('input[data-slot="session-title-child"]')
49
+ await expect(input).toBeFocused()
50
+ await input.fill("Renamed session")
51
+ if (commit === "Enter") await input.press("Enter")
52
+ if (commit === "blur") await input.press("Tab")
53
+ if (commit === "click outside") await page.getByRole("textbox", { name: "Prompt", exact: true }).click()
54
+ await expect(page.getByRole("heading", { name: "Renamed session", exact: true })).toBeVisible()
55
+ await expect(page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed session" })).toBeVisible()
56
+ await page.reload()
57
+ await expect(page.getByRole("heading", { name: "Renamed session", exact: true })).toBeVisible()
58
+ })
59
+ }
60
+
61
+ test("cancels the session heading with Escape", async ({ page }) => {
62
+ await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
63
+ const input = page.locator('input[data-slot="session-title-child"]')
64
+ await input.fill("Discard this title")
65
+ await input.press("Escape")
66
+ await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
67
+ await page.reload()
68
+ await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
69
+ })
70
+
71
+ test("keeps the draft when saving the session heading fails", async ({ page }) => {
72
+ await page.route(/\/session\/[^/]+(?:\?.*)?$/, (route) => {
73
+ if (route.request().method() !== "PATCH") return route.fallback()
74
+ return route.fulfill({ status: 500, headers: { "access-control-allow-origin": "*" } })
75
+ })
76
+ await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
77
+ const input = page.locator('input[data-slot="session-title-child"]')
78
+ await input.fill("Retry this title")
79
+ await input.press("Tab")
80
+ await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
81
+ await expect(input).toBeEnabled()
82
+ await expect(input).toHaveValue("Retry this title")
83
+ await expect(
84
+ page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle }),
85
+ ).toBeVisible()
86
+ })
87
+
88
+ test("does not save an empty session heading", async ({ page }) => {
89
+ await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
90
+ const input = page.locator('input[data-slot="session-title-child"]')
91
+ await input.fill(" ")
92
+ await input.press("Tab")
93
+ await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
94
+ await page.reload()
95
+ await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
96
+ })
97
+
98
+ test("renames and closes the session tab from its context menu", async ({ page }) => {
99
+ const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle })
100
+ await tab.click({ button: "right" })
101
+ await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeVisible()
102
+ await page.keyboard.press("Escape")
103
+ await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeHidden()
104
+ await expect(tab).toBeFocused()
105
+ await tab.press("Shift+F10")
106
+ await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
107
+ const input = page.locator('[data-slot="tab-title"][contenteditable="true"]')
108
+ await expect(input).toBeFocused()
109
+ await input.fill("Renamed from tab")
110
+ await input.press("Enter")
111
+ await expect(page.getByRole("heading", { name: "Renamed from tab", exact: true })).toBeVisible()
112
+ await page.reload()
113
+ await expect(page.getByRole("heading", { name: "Renamed from tab", exact: true })).toBeVisible()
114
+ const renamed = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed from tab" })
115
+ await renamed.click({ button: "right" })
116
+ await page.getByRole("menuitem", { name: "Close tab", exact: true }).click()
117
+ await expect(renamed).toBeHidden()
118
+ await page.getByRole("button", { name: "Home", exact: true }).click()
119
+ await expect(
120
+ page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from tab" }),
121
+ ).toBeVisible()
122
+ })
123
+
124
+ test("renames an inactive tab without switching sessions", async ({ page }) => {
125
+ await page.getByRole("button", { name: "Home", exact: true }).click()
126
+ await page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.sourceTitle }).click()
127
+ await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
128
+ const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle })
129
+ await tab.click({ button: "right" })
130
+ await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
131
+ const input = page.locator('[data-slot="tab-title"][contenteditable="true"]')
132
+ await expect(input).toBeFocused()
133
+ await input.fill("Inactive tab renamed")
134
+ await input.press("Tab")
135
+ await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
136
+ await expect(page).toHaveURL(new RegExp(`/session/${fixture.sourceID}$`))
137
+ await page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Inactive tab renamed" }).click()
138
+ await expect(page.getByRole("heading", { name: "Inactive tab renamed", exact: true })).toBeVisible()
139
+ await page.reload()
140
+ await expect(page.getByRole("heading", { name: "Inactive tab renamed", exact: true })).toBeVisible()
141
+ })
packages/app/e2e/regression/session-request-docks.spec.ts ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { base64Encode } from "@opencode-ai/core/util/encode"
2
+ import { expect, test, type Page } from "@playwright/test"
3
+ import { mockOpenCodeServer } from "../utils/mock-server"
4
+ import { installSseTransport } from "../utils/sse-transport"
5
+ import { expectSessionTitle } from "../utils/waits"
6
+
7
+ const directory = "C:/OpenCode/RequestDocks"
8
+ const projectID = "proj_request_docks"
9
+ const sessionID = "ses_request_docks"
10
+ const title = "Request dock regression"
11
+
12
+ test("shows a pending question dock", async ({ page }) => {
13
+ await mockServer(page, {
14
+ questions: [
15
+ {
16
+ id: "question-request",
17
+ sessionID,
18
+ questions: [
19
+ {
20
+ header: "Implementation",
21
+ question: "Which implementation should be used?",
22
+ options: [
23
+ { label: "Minimal", description: "Use the smallest correct change" },
24
+ { label: "Extended", description: "Include additional behavior" },
25
+ ],
26
+ },
27
+ ],
28
+ },
29
+ ],
30
+ })
31
+
32
+ await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
33
+ await expectSessionTitle(page, title)
34
+
35
+ const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
36
+ await expect(question).toBeVisible()
37
+ await expect(question.getByText("Which implementation should be used?")).toBeVisible()
38
+ await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible()
39
+ await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible()
40
+ await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
41
+
42
+ const rejectRequests: string[] = []
43
+ page.on("request", (request) => {
44
+ if (request.method() !== "POST") return
45
+ if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`)
46
+ rejectRequests.push(request.url())
47
+ })
48
+
49
+ await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
50
+ await expect(question).toBeVisible()
51
+ await expect(question.getByText("Which implementation should be used?")).toBeVisible()
52
+ await expect(question.getByText("Select one answer")).toBeHidden()
53
+ await expect(question.getByRole("radio", { name: /Minimal/ })).toBeHidden()
54
+ await expect(question.getByRole("radio", { name: /Extended/ })).toBeHidden()
55
+ await expect(question.getByRole("button", { name: "Dismiss" })).toBeVisible()
56
+ await expect(question.getByRole("button", { name: "Submit" })).toBeVisible()
57
+ await expect(page.locator('[data-component="question-minimized-dock"]')).toHaveCount(0)
58
+ expect(rejectRequests).toEqual([])
59
+
60
+ await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
61
+ await expect(question).toBeVisible()
62
+ await expect(question.getByText("Which implementation should be used?")).toBeVisible()
63
+ await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible()
64
+ expect(rejectRequests).toEqual([])
65
+
66
+ await question.getByRole("radio", { name: /Minimal/ }).click()
67
+ const reply = page.waitForRequest(
68
+ (request) =>
69
+ request.method() === "POST" &&
70
+ new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`,
71
+ )
72
+ await question.getByRole("button", { name: "Submit" }).click()
73
+ expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
74
+ })
75
+
76
+ test("shows a pending permission dock", async ({ page }) => {
77
+ await mockServer(page, {
78
+ permissions: [
79
+ {
80
+ id: "permission-request",
81
+ sessionID,
82
+ permission: "bash",
83
+ patterns: ["git status", "git diff"],
84
+ metadata: {},
85
+ always: [],
86
+ },
87
+ ],
88
+ })
89
+
90
+ await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
91
+ await expectSessionTitle(page, title)
92
+
93
+ const permission = page.locator('[data-component="dock-prompt"][data-kind="permission"]')
94
+ await expect(permission).toBeVisible()
95
+ await expect(permission.getByText("git status")).toBeVisible()
96
+ await expect(permission.getByText("git diff")).toBeVisible()
97
+ await expect(permission.locator('[data-slot="permission-footer-actions"] button')).toHaveCount(3)
98
+ await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
99
+
100
+ const reply = page.waitForRequest((request) => request.method() === "POST")
101
+ await permission.getByRole("button", { name: "Allow once" }).click()
102
+ const request = await reply
103
+ expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
104
+ expect(request.postDataJSON()).toEqual({ reply: "once" })
105
+ })
106
+
107
+ test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
108
+ const transport = await installSseTransport(page, {
109
+ server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
110
+ retry: 20,
111
+ })
112
+ await mockServer(page, { questions: [] })
113
+ await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
114
+ await transport.waitForConnection()
115
+ await expectSessionTitle(page, title)
116
+
117
+ const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
118
+ const draft = "keep the caret at the end"
119
+ await editor.fill(draft)
120
+ await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())))
121
+ for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft")
122
+ const cursor = draft.length - 4
123
+ await expect
124
+ .poll(() =>
125
+ editor.evaluate((element) => {
126
+ const selection = window.getSelection()
127
+ if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1
128
+ const range = selection.getRangeAt(0).cloneRange()
129
+ range.selectNodeContents(element)
130
+ range.setEnd(selection.anchorNode!, selection.anchorOffset)
131
+ return range.toString().length
132
+ }),
133
+ )
134
+ .toBe(cursor)
135
+ await transport.send({
136
+ directory,
137
+ payload: {
138
+ type: "question.asked",
139
+ properties: {
140
+ id: "question-caret",
141
+ sessionID,
142
+ questions: [
143
+ {
144
+ header: "Continue",
145
+ question: "Continue?",
146
+ options: [{ label: "Yes", description: "Continue the session" }],
147
+ },
148
+ ],
149
+ tool: { messageID: "message-caret", callID: "call-caret" },
150
+ },
151
+ },
152
+ })
153
+ const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
154
+ await expect(question).toBeVisible()
155
+ await expect(editor).toHaveCount(0)
156
+
157
+ await transport.send({
158
+ directory,
159
+ payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
160
+ })
161
+ await expect(question).toHaveCount(0)
162
+ await expect(editor).toBeVisible()
163
+ await page.keyboard.press("x")
164
+
165
+ await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`)
166
+ })
167
+
168
+ async function mockServer(
169
+ page: Page,
170
+ requests: {
171
+ permissions?: unknown[] | (() => unknown[])
172
+ questions?: unknown[] | (() => unknown[])
173
+ },
174
+ ) {
175
+ await mockOpenCodeServer(page, {
176
+ protocol: "v2",
177
+ directory,
178
+ project: {
179
+ id: projectID,
180
+ worktree: directory,
181
+ vcs: "git",
182
+ name: "request-docks",
183
+ time: { created: 1700000000000, updated: 1700000000000 },
184
+ sandboxes: [],
185
+ },
186
+ provider: {
187
+ all: [
188
+ {
189
+ id: "opencode",
190
+ name: "OpenCode",
191
+ models: {
192
+ "claude-opus-4-6": {
193
+ id: "claude-opus-4-6",
194
+ name: "Claude Opus 4.6",
195
+ limit: { context: 200_000 },
196
+ },
197
+ },
198
+ },
199
+ ],
200
+ connected: ["opencode"],
201
+ default: { providerID: "opencode", modelID: "claude-opus-4-6" },
202
+ },
203
+ sessions: [
204
+ {
205
+ id: sessionID,
206
+ slug: "request-docks",
207
+ projectID,
208
+ directory,
209
+ title,
210
+ version: "dev",
211
+ time: { created: 1700000000000, updated: 1700000000000 },
212
+ },
213
+ ],
214
+ pageMessages: () => ({ items: [] }),
215
+ permissions: requests.permissions,
216
+ questions: requests.questions,
217
+ })
218
+ await page.addInitScript(() => {
219
+ localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
220
+ })
221
+ }
packages/app/e2e/regression/session-timeline-accessibility.spec.ts ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import { assistantMessage, setupTimeline, shell, userMessage } from "../performance/timeline-stability/fixture"
3
+
4
+ test("space activates a focused timeline button instead of scrolling", async ({ page }) => {
5
+ const shellID = "prt_space_button_shell"
6
+ await setupTimeline(page, {
7
+ messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
8
+ settings: { shellToolPartsExpanded: false },
9
+ reducedMotion: true,
10
+ })
11
+ const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
12
+ const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
13
+ await trigger.focus()
14
+ const before = await scroller.evaluate((element) => element.scrollTop)
15
+ await trigger.press("Space")
16
+ await expect(trigger).toHaveAttribute("aria-expanded", "true")
17
+ expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before)
18
+ })
19
+
20
+ function lines(count: number) {
21
+ return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
22
+ }
packages/app/e2e/regression/session-timeline-collapse-state.spec.ts ADDED
@@ -0,0 +1,439 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test, type Locator, type Page } from "@playwright/test"
2
+ import { mockOpenCodeServer } from "../utils/mock-server"
3
+ import { expectAppVisible, expectSessionTitle } from "../utils/waits"
4
+
5
+ const directory = "C:/OpenCode/TimelineStateRegression"
6
+ const projectID = "proj_timeline_state_regression"
7
+ const sessionID = "ses_timeline_state_regression"
8
+ const userMessageID = "msg_user_regression"
9
+ const assistantMessageID = "msg_assistant_regression"
10
+ const editPartID = "prt_0001_edit"
11
+ const textPartID = "prt_9999_text"
12
+ const title = "Timeline collapse state regression"
13
+ const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
14
+
15
+ type EventPayload = {
16
+ directory: string
17
+ payload: Record<string, unknown>
18
+ }
19
+
20
+ declare global {
21
+ interface Window {
22
+ __timelineDiffProbe: {
23
+ reset: () => void
24
+ shadowRoots: () => number
25
+ }
26
+ }
27
+ }
28
+
29
+ const userMessage = {
30
+ info: {
31
+ id: userMessageID,
32
+ sessionID,
33
+ role: "user",
34
+ time: { created: 1700000000000 },
35
+ summary: { diffs: [] },
36
+ agent: "build",
37
+ model,
38
+ },
39
+ parts: [
40
+ {
41
+ id: "prt_user_text",
42
+ sessionID,
43
+ messageID: userMessageID,
44
+ type: "text",
45
+ text: "Please edit the file.",
46
+ },
47
+ ],
48
+ }
49
+
50
+ const editPart = {
51
+ id: editPartID,
52
+ sessionID,
53
+ messageID: assistantMessageID,
54
+ type: "tool",
55
+ callID: "call_edit_regression",
56
+ tool: "edit",
57
+ state: {
58
+ status: "completed",
59
+ input: { filePath: "src/regression.ts" },
60
+ output: "Edited src/regression.ts",
61
+ title: "src/regression.ts",
62
+ metadata: {
63
+ filediff: {
64
+ file: "src/regression.ts",
65
+ additions: 1,
66
+ deletions: 1,
67
+ before: "export const value = 'before'\n",
68
+ after: "export const value = 'after'\n",
69
+ },
70
+ diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
71
+ },
72
+ time: { start: 1700000001000, end: 1700000002000 },
73
+ },
74
+ }
75
+
76
+ const streamedTextPart = {
77
+ id: textPartID,
78
+ sessionID,
79
+ messageID: assistantMessageID,
80
+ type: "text",
81
+ text: "Streaming added a later assistant text part.",
82
+ }
83
+
84
+ const assistantMessage = {
85
+ info: {
86
+ id: assistantMessageID,
87
+ sessionID,
88
+ role: "assistant",
89
+ time: { created: 1700000001000 },
90
+ parentID: userMessageID,
91
+ modelID: model.modelID,
92
+ providerID: model.providerID,
93
+ mode: "build",
94
+ agent: "build",
95
+ path: { cwd: directory, root: directory },
96
+ cost: 0.01,
97
+ tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
98
+ variant: "max",
99
+ },
100
+ parts: [editPart],
101
+ }
102
+
103
+ test.describe("regression: session timeline local row state", () => {
104
+ test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
105
+ const events: EventPayload[] = []
106
+ await mockServer(page, events)
107
+ await configurePage(page)
108
+
109
+ await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
110
+ await expectSessionTitle(page, title)
111
+
112
+ const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
113
+ await expectAppVisible(wrapper)
114
+ await expectExpanded(wrapper, true)
115
+
116
+ await wrapper.evaluate((element) => {
117
+ ;(element as HTMLElement).dataset.regressionMarker = "before-stream"
118
+ })
119
+ await wrapper.locator('[data-slot="collapsible-trigger"]').first().click()
120
+ await expectExpanded(wrapper, false)
121
+
122
+ events.push({
123
+ directory,
124
+ payload: {
125
+ type: "message.part.updated",
126
+ properties: { part: streamedTextPart },
127
+ },
128
+ })
129
+
130
+ await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
131
+
132
+ expect(await readToolState(page)).toEqual({
133
+ expanded: false,
134
+ row: "AssistantPart",
135
+ streamedTextVisible: true,
136
+ })
137
+ })
138
+
139
+ test("does not remount an edit diff when sibling parts or diff counts update", async ({ page }) => {
140
+ const events: EventPayload[] = []
141
+ await installDiffProbe(page)
142
+ await mockServer(page, events)
143
+ await configurePage(page)
144
+
145
+ await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
146
+ await expectSessionTitle(page, title)
147
+
148
+ const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
149
+ await expectAppVisible(wrapper)
150
+ const file = wrapper.locator('[data-component="file"][data-mode="diff"]').first()
151
+ await expectAppVisible(file)
152
+ await markDiffProbe(page)
153
+
154
+ events.push({
155
+ directory,
156
+ payload: {
157
+ type: "message.part.updated",
158
+ properties: { part: streamedTextPart },
159
+ },
160
+ })
161
+
162
+ await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
163
+ const siblingProbe = await readDiffProbe(page)
164
+ expect(siblingProbe).toEqual({
165
+ fileMarker: "before",
166
+ frameMarker: "before",
167
+ rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
168
+ rowMarker: "before",
169
+ shadowRoots: 0,
170
+ toolMarker: "before",
171
+ })
172
+
173
+ await markDiffProbe(page)
174
+ events.push({
175
+ directory,
176
+ payload: {
177
+ type: "message.part.updated",
178
+ properties: { part: editPartWithAdditions(2) },
179
+ },
180
+ })
181
+
182
+ await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible(
183
+ { timeout: 10_000 },
184
+ )
185
+ expect(await readDiffProbe(page)).toEqual({
186
+ fileMarker: "before",
187
+ frameMarker: "before",
188
+ rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
189
+ rowMarker: "before",
190
+ shadowRoots: 0,
191
+ toolMarker: "before",
192
+ })
193
+ })
194
+
195
+ test("keeps a sticky edit header aligned with a multi-hunk diff", async ({ page }) => {
196
+ const events: EventPayload[] = []
197
+ const lines = Array.from({ length: 1_000 }, (_, index) => `export const value${index} = ${index}\n`).join("")
198
+ const after = [100, 300, 500, 700, 900].reduce(
199
+ (result, index) =>
200
+ result.replace(`export const value${index} = ${index}`, `export const value${index} = compute(${index})`),
201
+ lines,
202
+ )
203
+ const part = {
204
+ ...editPart,
205
+ state: {
206
+ ...editPart.state,
207
+ metadata: {
208
+ ...editPart.state.metadata,
209
+ filediff: {
210
+ file: "src/regression.ts",
211
+ additions: 1,
212
+ deletions: 1,
213
+ before: lines,
214
+ after,
215
+ },
216
+ },
217
+ },
218
+ }
219
+ await mockServer(page, events, [userMessage, { ...assistantMessage, parts: [part] }])
220
+ await configurePage(page)
221
+
222
+ await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
223
+ await expectSessionTitle(page, title)
224
+
225
+ const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
226
+ const trigger = wrapper.locator('[data-slot="collapsible-trigger"]').first()
227
+ const diff = wrapper.locator('[data-component="edit-content"]').first()
228
+ await expectAppVisible(diff)
229
+ await expect.poll(() => wrapper.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(500)
230
+ const samples = await wrapper.evaluate(async (element) => {
231
+ const root = element.closest<HTMLElement>(".scroll-view__viewport")!
232
+ element.scrollIntoView({ block: "start" })
233
+ const result = []
234
+ for (const offset of [0, 120, 240, 360, 480]) {
235
+ root.scrollBy(0, offset - (result.at(-1)?.offset ?? 0))
236
+ await new Promise(requestAnimationFrame)
237
+ const trigger = element.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')!
238
+ const diff = element.querySelector<HTMLElement>('[data-component="edit-content"]')!
239
+ result.push({
240
+ offset,
241
+ trigger: trigger.getBoundingClientRect().y,
242
+ diff: diff.getBoundingClientRect().y,
243
+ bottom: element.getBoundingClientRect().bottom,
244
+ })
245
+ }
246
+ return result
247
+ })
248
+
249
+ expect(samples[0]!.trigger).toBeLessThan(samples[0]!.diff)
250
+ expect(samples.every((sample) => Math.abs(sample.trigger - samples[0]!.trigger) <= 1)).toBe(true)
251
+ expect(samples.every((sample) => sample.trigger < sample.bottom)).toBe(true)
252
+ })
253
+ })
254
+
255
+ async function configurePage(page: Page) {
256
+ await page.addInitScript(() => {
257
+ localStorage.setItem(
258
+ "settings.v3",
259
+ JSON.stringify({
260
+ general: {
261
+ editToolPartsExpanded: true,
262
+ shellToolPartsExpanded: true,
263
+ showReasoningSummaries: true,
264
+ },
265
+ }),
266
+ )
267
+ })
268
+ }
269
+
270
+ async function expectExpanded(locator: Locator, expected: boolean) {
271
+ await expect.poll(() => locator.evaluate(readExpanded)).toBe(expected)
272
+ }
273
+
274
+ async function readToolState(page: Page) {
275
+ return page
276
+ .locator(`[data-timeline-part-id="${editPartID}"]`)
277
+ .first()
278
+ .evaluate(
279
+ (element, textPartID) => ({
280
+ expanded: (() => {
281
+ const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
282
+ const aria = trigger?.getAttribute("aria-expanded")
283
+ if (aria === "true") return true
284
+ if (aria === "false") return false
285
+
286
+ const root = element.querySelector('[data-component="collapsible"]')
287
+ if (root?.hasAttribute("data-expanded")) return true
288
+ if (root?.hasAttribute("data-closed")) return false
289
+
290
+ const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
291
+ return !!content && content.getBoundingClientRect().height > 0
292
+ })(),
293
+ row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"),
294
+ streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`),
295
+ }),
296
+ textPartID,
297
+ )
298
+ }
299
+
300
+ async function installDiffProbe(page: Page) {
301
+ await page.addInitScript(() => {
302
+ let shadowRootCount = 0
303
+ const attachShadow = Element.prototype.attachShadow
304
+ Element.prototype.attachShadow = function (init) {
305
+ shadowRootCount += 1
306
+ return attachShadow.call(this, init)
307
+ }
308
+ window.__timelineDiffProbe = {
309
+ reset: () => {
310
+ shadowRootCount = 0
311
+ },
312
+ shadowRoots: () => shadowRootCount,
313
+ }
314
+ })
315
+ }
316
+
317
+ async function markDiffProbe(page: Page) {
318
+ await page
319
+ .locator(`[data-timeline-part-id="${editPartID}"]`)
320
+ .first()
321
+ .evaluate((element) => {
322
+ const tool = element as HTMLElement
323
+ const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
324
+ const row = tool.closest<HTMLElement>("[data-timeline-key]")
325
+ const frame = tool.closest<HTMLElement>("[data-timeline-row]")
326
+ if (!file) throw new Error("missing edit diff file")
327
+ if (!row) throw new Error("missing virtual timeline row")
328
+ if (!frame) throw new Error("missing timeline row frame")
329
+
330
+ tool.dataset.timelineProbe = "before"
331
+ file.dataset.timelineProbe = "before"
332
+ row.dataset.timelineProbe = "before"
333
+ frame.dataset.timelineProbe = "before"
334
+ window.__timelineDiffProbe.reset()
335
+ })
336
+ }
337
+
338
+ async function readDiffProbe(page: Page) {
339
+ return page
340
+ .locator(`[data-timeline-part-id="${editPartID}"]`)
341
+ .first()
342
+ .evaluate((element) => {
343
+ const tool = element as HTMLElement
344
+ const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
345
+ const row = tool.closest<HTMLElement>("[data-timeline-key]")
346
+ const frame = tool.closest<HTMLElement>("[data-timeline-row]")
347
+ return {
348
+ fileMarker: file?.dataset.timelineProbe,
349
+ shadowRoots: window.__timelineDiffProbe.shadowRoots(),
350
+ toolMarker: tool.dataset.timelineProbe,
351
+ rowMarker: row?.dataset.timelineProbe,
352
+ rowKey: row?.dataset.timelineKey,
353
+ frameMarker: frame?.dataset.timelineProbe,
354
+ }
355
+ })
356
+ }
357
+
358
+ function editPartWithAdditions(additions: number) {
359
+ return {
360
+ ...editPart,
361
+ state: {
362
+ ...editPart.state,
363
+ metadata: {
364
+ ...editPart.state.metadata,
365
+ filediff: {
366
+ ...editPart.state.metadata.filediff,
367
+ additions,
368
+ },
369
+ },
370
+ },
371
+ }
372
+ }
373
+
374
+ function readExpanded(element: Element) {
375
+ const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
376
+ const aria = trigger?.getAttribute("aria-expanded")
377
+ if (aria === "true") return true
378
+ if (aria === "false") return false
379
+
380
+ const root = element.querySelector('[data-component="collapsible"]')
381
+ if (root?.hasAttribute("data-expanded")) return true
382
+ if (root?.hasAttribute("data-closed")) return false
383
+
384
+ const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
385
+ return !!content && content.getBoundingClientRect().height > 0
386
+ }
387
+
388
+ async function mockServer(page: Page, events: EventPayload[], messages = [userMessage, assistantMessage]) {
389
+ await mockOpenCodeServer(page, {
390
+ directory,
391
+ project: project(),
392
+ provider: provider(),
393
+ sessions: [session()],
394
+ pageMessages: () => ({ items: messages }),
395
+ events: () => events.splice(0, 1),
396
+ eventRetry: 16,
397
+ })
398
+ }
399
+
400
+ function project() {
401
+ return {
402
+ id: projectID,
403
+ worktree: directory,
404
+ vcs: "git",
405
+ name: "timeline-state-regression",
406
+ time: { created: 1700000000000, updated: 1700000000000 },
407
+ sandboxes: [],
408
+ }
409
+ }
410
+
411
+ function session() {
412
+ return {
413
+ id: sessionID,
414
+ slug: "timeline-state-regression",
415
+ projectID,
416
+ directory,
417
+ title,
418
+ version: "dev",
419
+ time: { created: 1700000000000, updated: 1700000000000 },
420
+ }
421
+ }
422
+
423
+ function provider() {
424
+ return {
425
+ all: [
426
+ {
427
+ id: "opencode",
428
+ name: "OpenCode",
429
+ models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
430
+ },
431
+ ],
432
+ connected: ["opencode"],
433
+ default: { providerID: "opencode", modelID: "claude-opus-4-6" },
434
+ }
435
+ }
436
+
437
+ function base64Encode(value: string) {
438
+ return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
439
+ }
packages/app/e2e/regression/session-timeline-context-resize.spec.ts ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test, type Page } from "@playwright/test"
2
+ import { mockOpenCodeServer } from "../utils/mock-server"
3
+ import { expectAppVisible, expectSessionTitle } from "../utils/waits"
4
+ import {
5
+ analyzeVisualObservations,
6
+ defineVisualRegions,
7
+ startVisualProbe,
8
+ stopVisualProbe,
9
+ visualPlan,
10
+ } from "../utils/visual-stability"
11
+
12
+ const directory = "C:/OpenCode/ContextResizeRegression"
13
+ const projectID = "proj_context_resize_regression"
14
+ const sessionID = "ses_context_resize_regression"
15
+ const title = "Context resize regression"
16
+ const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
17
+ const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"]
18
+ const followingTextID = "prt_0104_text"
19
+
20
+ type Message = {
21
+ info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
22
+ parts: Record<string, unknown>[]
23
+ }
24
+
25
+ const messages = [...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(), ...turn(10, true)]
26
+
27
+ test.describe("regression: session timeline context group resize", () => {
28
+ test("remeasures a recent explored context group before the next paint", async ({ page }) => {
29
+ await page.setViewportSize({ width: 1400, height: 900 })
30
+ await mockServer(page)
31
+ await configurePage(page)
32
+
33
+ await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
34
+ await expectSessionTitle(page, title)
35
+ await expectAppVisible(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first())
36
+ await expectAppVisible(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first())
37
+ await settle(page)
38
+
39
+ const samples = await sampleExpansion(page)
40
+ const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5)
41
+
42
+ expect(samples[0]?.overlap).toBe(0)
43
+ expect(visibleOverlap).toEqual([])
44
+ expect(samples.at(-1)?.expanded).toBe("true")
45
+ })
46
+
47
+ test("paints a stable exploring to explored transition", async ({ page }) => {
48
+ const events: { directory: string; payload: Record<string, unknown> }[] = []
49
+ await page.setViewportSize({ width: 1400, height: 900 })
50
+ await mockServer(page, events, [
51
+ ...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(),
52
+ ...turn(10, true, "running"),
53
+ ])
54
+ await configurePage(page)
55
+
56
+ await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
57
+ await expectSessionTitle(page, title)
58
+ const devtools = await page.context().newCDPSession(page)
59
+ await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
60
+ const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()
61
+ await expectAppVisible(context)
62
+ await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Exploring")
63
+
64
+ const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
65
+ const regions = defineVisualRegions({
66
+ status: {
67
+ selector: `${contextSelector} [data-component="tool-status-title"]`,
68
+ opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'],
69
+ },
70
+ context: { selector: contextSelector, closest: '[data-timeline-row="AssistantPart"]' },
71
+ following: {
72
+ selector: `[data-timeline-part-id="${followingTextID}"]`,
73
+ closest: '[data-timeline-row="AssistantPart"]',
74
+ },
75
+ })
76
+ await startVisualProbe(page, regions)
77
+ for (const [index, delay] of [120, 350, 80, 500].entries()) {
78
+ events.push({
79
+ directory,
80
+ payload: {
81
+ type: "message.part.updated",
82
+ properties: {
83
+ part: contextTool(
84
+ contextIDs[index]!,
85
+ id("msg_assistant", 10),
86
+ ["read", "glob", "grep", "list"][index]!,
87
+ [
88
+ { filePath: "src/recent-a.ts" },
89
+ { path: directory, pattern: "**/*.ts" },
90
+ { path: directory, pattern: "Explored" },
91
+ { path: "src" },
92
+ ][index]!,
93
+ ),
94
+ },
95
+ },
96
+ })
97
+ await page.waitForTimeout(delay)
98
+ }
99
+
100
+ await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
101
+ await page.waitForTimeout(700)
102
+ const trace = await stopVisualProbe<keyof typeof regions>(page)
103
+ const labels = trace.samples
104
+ .map((sample) => sample.regions.status?.label)
105
+ .filter((value): value is string => !!value)
106
+ .filter((value, index, all) => value !== all[index - 1])
107
+ const issues = analyzeVisualObservations(
108
+ trace.samples,
109
+ visualPlan(regions, [
110
+ { type: "required", regions: ["context", "following"] },
111
+ { type: "opacity", regions: "all" },
112
+ { type: "continuity", regions: "all" },
113
+ { type: "motion", regions: "all" },
114
+ { type: "label-stability", regions: "all" },
115
+ { type: "flow", regions: ["context", "following"] },
116
+ ]),
117
+ )
118
+
119
+ expect(labels).toEqual(["Exploring", "Explored"])
120
+ expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
121
+ })
122
+ })
123
+
124
+ async function configurePage(page: Page) {
125
+ await page.addInitScript(() => {
126
+ localStorage.setItem(
127
+ "settings.v3",
128
+ JSON.stringify({
129
+ general: {
130
+ editToolPartsExpanded: true,
131
+ shellToolPartsExpanded: true,
132
+ showReasoningSummaries: true,
133
+ },
134
+ }),
135
+ )
136
+ })
137
+ }
138
+
139
+ async function sampleExpansion(page: Page) {
140
+ return page.evaluate(
141
+ ({ contextIDs, followingTextID }) =>
142
+ new Promise<
143
+ {
144
+ frame: number
145
+ label: string
146
+ scrollTop: number
147
+ scrollHeight: number
148
+ contextBottom: number
149
+ textTop: number
150
+ overlap: number
151
+ gap: number
152
+ expanded: string | null
153
+ }[]
154
+ >((resolve) => {
155
+ const context = document.querySelector<HTMLElement>(`[data-timeline-part-ids="${contextIDs.join(",")}"]`)
156
+ const text = document.querySelector<HTMLElement>(`[data-timeline-part-id="${followingTextID}"]`)
157
+ const scroller = context?.closest<HTMLElement>(".scroll-view__viewport")
158
+ const trigger = context?.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')
159
+ const contextRow = context?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
160
+ const textRow = text?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
161
+ if (!context || !text || !scroller || !trigger || !contextRow || !textRow)
162
+ throw new Error("missing regression nodes")
163
+
164
+ scroller.scrollTop = scroller.scrollHeight
165
+ const samples: {
166
+ frame: number
167
+ label: string
168
+ scrollTop: number
169
+ scrollHeight: number
170
+ contextBottom: number
171
+ textTop: number
172
+ overlap: number
173
+ gap: number
174
+ expanded: string | null
175
+ }[] = []
176
+ const capture = (frame: number, label: string) => {
177
+ const contextRect = contextRow.getBoundingClientRect()
178
+ const textRect = textRow.getBoundingClientRect()
179
+ samples.push({
180
+ frame,
181
+ label,
182
+ scrollTop: Math.round(scroller.scrollTop * 10) / 10,
183
+ scrollHeight: Math.round(scroller.scrollHeight * 10) / 10,
184
+ contextBottom: Math.round(contextRect.bottom * 10) / 10,
185
+ textTop: Math.round(textRect.top * 10) / 10,
186
+ overlap: Math.max(0, Math.round((contextRect.bottom - textRect.top) * 10) / 10),
187
+ gap: Math.max(0, Math.round((textRect.top - contextRect.bottom) * 10) / 10),
188
+ expanded: trigger.getAttribute("aria-expanded"),
189
+ })
190
+ }
191
+
192
+ capture(-1, "before")
193
+ trigger.click()
194
+ capture(0, "sync-after-click")
195
+
196
+ let frame = 1
197
+ const tick = () => {
198
+ setTimeout(() => {
199
+ capture(frame, "painted")
200
+ frame += 1
201
+ if (frame > 8) {
202
+ resolve(samples)
203
+ return
204
+ }
205
+ requestAnimationFrame(tick)
206
+ }, 0)
207
+ }
208
+ requestAnimationFrame(tick)
209
+ }),
210
+ { contextIDs, followingTextID },
211
+ )
212
+ }
213
+
214
+ function turn(index: number, target: boolean, status: "running" | "completed" = "completed"): Message[] {
215
+ const userID = id("msg_user", index)
216
+ const assistantID = id("msg_assistant", index)
217
+ return [
218
+ {
219
+ info: {
220
+ id: userID,
221
+ sessionID,
222
+ role: "user",
223
+ time: { created: 1700000000000 + index * 10_000 },
224
+ summary: { diffs: [] },
225
+ agent: "build",
226
+ model,
227
+ },
228
+ parts: [{ id: id("prt_user", index), sessionID, messageID: userID, type: "text", text: `User message ${index}` }],
229
+ },
230
+ {
231
+ info: {
232
+ id: assistantID,
233
+ sessionID,
234
+ role: "assistant",
235
+ time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 2_000 },
236
+ parentID: userID,
237
+ modelID: model.modelID,
238
+ providerID: model.providerID,
239
+ mode: "build",
240
+ agent: "build",
241
+ path: { cwd: directory, root: directory },
242
+ cost: 0.01,
243
+ tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
244
+ variant: "max",
245
+ finish: "stop",
246
+ },
247
+ parts: target
248
+ ? [
249
+ contextTool(
250
+ contextIDs[0]!,
251
+ assistantID,
252
+ "read",
253
+ { filePath: "src/recent-a.ts", offset: 0, limit: 120 },
254
+ status,
255
+ ),
256
+ contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status),
257
+ contextTool(
258
+ contextIDs[2]!,
259
+ assistantID,
260
+ "grep",
261
+ { path: directory, pattern: "Explored", include: "*.ts" },
262
+ status,
263
+ ),
264
+ contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
265
+ {
266
+ id: followingTextID,
267
+ sessionID,
268
+ messageID: assistantID,
269
+ type: "text",
270
+ text: "This assistant text is immediately after the explored context group.",
271
+ },
272
+ ]
273
+ : [
274
+ {
275
+ id: id("prt_text", index),
276
+ sessionID,
277
+ messageID: assistantID,
278
+ type: "text",
279
+ text: `Assistant filler ${index}. ${"filler ".repeat(60)}`,
280
+ },
281
+ ],
282
+ },
283
+ ]
284
+ }
285
+
286
+ function contextTool(
287
+ partID: string,
288
+ messageID: string,
289
+ tool: string,
290
+ input: Record<string, unknown>,
291
+ status: "running" | "completed" = "completed",
292
+ ) {
293
+ return {
294
+ id: partID,
295
+ sessionID,
296
+ messageID,
297
+ type: "tool",
298
+ callID: `call_${partID}`,
299
+ tool,
300
+ state: {
301
+ status,
302
+ input,
303
+ output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`,
304
+ title: input.filePath || input.path || input.pattern || "completed",
305
+ metadata: {},
306
+ time: { start: 1700000000000, end: 1700000000100 },
307
+ },
308
+ }
309
+ }
310
+
311
+ async function mockServer(
312
+ page: Page,
313
+ events: { directory: string; payload: Record<string, unknown> }[] = [],
314
+ fixtureMessages = messages,
315
+ ) {
316
+ await mockOpenCodeServer(page, {
317
+ directory,
318
+ project: project(),
319
+ provider: provider(),
320
+ sessions: [session()],
321
+ pageMessages: () => ({ items: fixtureMessages }),
322
+ events: () => events.splice(0, 1),
323
+ eventRetry: 50,
324
+ })
325
+ }
326
+
327
+ async function settle(page: Page) {
328
+ await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))
329
+ }
330
+
331
+ function id(prefix: string, index: number) {
332
+ return `${prefix}_${String(index).padStart(4, "0")}`
333
+ }
334
+
335
+ function project() {
336
+ return {
337
+ id: projectID,
338
+ worktree: directory,
339
+ vcs: "git",
340
+ name: "context-resize-regression",
341
+ time: { created: 1700000000000, updated: 1700000000000 },
342
+ sandboxes: [],
343
+ }
344
+ }
345
+
346
+ function session() {
347
+ return {
348
+ id: sessionID,
349
+ slug: "context-resize-regression",
350
+ projectID,
351
+ directory,
352
+ title,
353
+ version: "dev",
354
+ time: { created: 1700000000000, updated: 1700000000000 },
355
+ }
356
+ }
357
+
358
+ function provider() {
359
+ return {
360
+ all: [
361
+ {
362
+ id: "opencode",
363
+ name: "OpenCode",
364
+ models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
365
+ },
366
+ ],
367
+ connected: ["opencode"],
368
+ default: { providerID: "opencode", modelID: "claude-opus-4-6" },
369
+ }
370
+ }
371
+
372
+ function base64Encode(value: string) {
373
+ return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
374
+ }
packages/app/e2e/regression/session-timeline-context-state.spec.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import {
3
+ assistantMessage,
4
+ partUpdated,
5
+ setupTimeline,
6
+ toolPart,
7
+ userMessage,
8
+ } from "../performance/timeline-stability/fixture"
9
+
10
+ test("preserves a collapsed context group through count and status updates", async ({ page }) => {
11
+ const ids = ["prt_closed_01_read", "prt_closed_02_glob"]
12
+ const inputs = {
13
+ read: { filePath: "src/a.ts", offset: 0, limit: 120 },
14
+ glob: { path: ".", pattern: "**/*.ts" },
15
+ }
16
+ const timeline = await setupTimeline(page, {
17
+ messages: [
18
+ userMessage(),
19
+ assistantMessage(
20
+ [toolPart(ids[0]!, "read", "running", inputs.read), toolPart(ids[1]!, "glob", "running", inputs.glob)],
21
+ { completed: false },
22
+ ),
23
+ ],
24
+ })
25
+ const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
26
+ const trigger = group.locator('[data-slot="collapsible-trigger"]')
27
+ await expect(trigger).toHaveAttribute("aria-expanded", "false")
28
+ await timeline.send(partUpdated(toolPart(ids[0]!, "read", "completed", inputs.read)), 100)
29
+ await timeline.send(partUpdated(toolPart(ids[1]!, "glob", "completed", inputs.glob)), 300)
30
+ await expect(trigger).toHaveAttribute("aria-expanded", "false")
31
+ })
packages/app/e2e/regression/session-timeline-file-projection.spec.ts ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
3
+
4
+ test("renders completed write content", async ({ page }) => {
5
+ const id = "prt_file_projection_write"
6
+ await setupTimeline(page, {
7
+ messages: [
8
+ userMessage(),
9
+ assistantMessage([
10
+ toolPart(id, "write", "completed", { filePath: "src/write.ts", content: "export const written = true\n" }),
11
+ ]),
12
+ ],
13
+ settings: { editToolPartsExpanded: true },
14
+ })
15
+
16
+ await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="write-content"]`)).toBeVisible()
17
+ })
18
+
19
+ test("renders a completed single-file patch", async ({ page }) => {
20
+ const id = "prt_file_projection_single_patch"
21
+ await setupTimeline(page, {
22
+ messages: [
23
+ userMessage(),
24
+ assistantMessage([
25
+ toolPart(
26
+ id,
27
+ "apply_patch",
28
+ "completed",
29
+ { files: ["src/a.ts"] },
30
+ {
31
+ metadata: {
32
+ files: [
33
+ {
34
+ filePath: "src/a.ts",
35
+ relativePath: "src/a.ts",
36
+ type: "update",
37
+ additions: 1,
38
+ deletions: 1,
39
+ before: "export const value = 1\n",
40
+ after: "export const value = 2\n",
41
+ },
42
+ ],
43
+ },
44
+ },
45
+ ),
46
+ ]),
47
+ ],
48
+ settings: { editToolPartsExpanded: true },
49
+ })
50
+
51
+ await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="apply-patch-file-diff"]`)).toBeVisible()
52
+ })
packages/app/e2e/regression/session-timeline-file-state.spec.ts ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import {
3
+ assistantMessage,
4
+ partUpdated,
5
+ setupTimeline,
6
+ toolPart,
7
+ userMessage,
8
+ } from "../performance/timeline-stability/fixture"
9
+
10
+ test("updates edit diagnostics without resetting manual collapse state", async ({ page }) => {
11
+ const editID = "prt_diagnostics_edit"
12
+ const base = editPart(editID, [])
13
+ const timeline = await setupTimeline(page, {
14
+ messages: [userMessage(), assistantMessage([base])],
15
+ settings: { editToolPartsExpanded: true },
16
+ })
17
+ const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first()
18
+ await trigger.click()
19
+ await expect(trigger).toHaveAttribute("aria-expanded", "false")
20
+ await timeline.send(
21
+ partUpdated(editPart(editID, [diagnostic("First failure", 2), diagnostic("Second failure", 4)])),
22
+ 300,
23
+ )
24
+ await expect(trigger).toHaveAttribute("aria-expanded", "false")
25
+ await timeline.send(partUpdated(editPart(editID, [])), 300)
26
+ await expect(trigger).toHaveAttribute("aria-expanded", "false")
27
+ })
28
+
29
+ test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => {
30
+ const patchID = "prt_nested_patch"
31
+ const files = [patchFile("src/a.ts", "update"), patchFile("src/b.ts", "add"), patchFile("src/old.ts", "delete")]
32
+ await setupTimeline(page, {
33
+ messages: [
34
+ userMessage(),
35
+ assistantMessage([
36
+ toolPart(
37
+ patchID,
38
+ "apply_patch",
39
+ "completed",
40
+ { files: files.map((file) => file.filePath) },
41
+ { metadata: { files } },
42
+ ),
43
+ ]),
44
+ ],
45
+ settings: { editToolPartsExpanded: true },
46
+ })
47
+ const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`)
48
+ const outer = wrapper.locator('[data-slot="collapsible-trigger"]').first()
49
+ const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"]')
50
+ await deleted.getByRole("button").click()
51
+ await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
52
+ await outer.click()
53
+ await expect(outer).toHaveAttribute("aria-expanded", "false")
54
+ await outer.click()
55
+ await expect(outer).toHaveAttribute("aria-expanded", "true")
56
+ await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
57
+ })
58
+
59
+ function patchFile(filePath: string, type: "add" | "update" | "delete") {
60
+ return {
61
+ filePath,
62
+ relativePath: filePath,
63
+ type,
64
+ additions: type === "delete" ? 0 : 4,
65
+ deletions: type === "add" ? 0 : 3,
66
+ before: type === "add" ? undefined : source(false),
67
+ after: type === "delete" ? undefined : source(true),
68
+ }
69
+ }
70
+
71
+ function editPart(id: string, diagnostics: Record<string, unknown>[]) {
72
+ return toolPart(
73
+ id,
74
+ "edit",
75
+ "completed",
76
+ { filePath: "src/edit.ts" },
77
+ {
78
+ metadata: {
79
+ filediff: { file: "src/edit.ts", additions: 1, deletions: 1, before: source(false), after: source(true) },
80
+ diagnostics,
81
+ },
82
+ },
83
+ )
84
+ }
85
+
86
+ function diagnostic(message: string, line: number) {
87
+ return { message, severity: 1, range: { start: { line, character: 0 }, end: { line, character: 2 } } }
88
+ }
89
+
90
+ function source(changed: boolean) {
91
+ return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
92
+ "",
93
+ )
94
+ }
packages/app/e2e/regression/session-timeline-history-root.spec.ts ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { base64Encode } from "@opencode-ai/core/util/encode"
2
+ import { expect, test, type Page } from "@playwright/test"
3
+ import {
4
+ assistantMessage,
5
+ directory,
6
+ messageUpdated,
7
+ project,
8
+ session,
9
+ sessionID,
10
+ status,
11
+ textPart,
12
+ title,
13
+ userID,
14
+ userMessage,
15
+ } from "../performance/timeline-stability/fixture"
16
+ import { mockOpenCodeServer } from "../utils/mock-server"
17
+ import { installSseTransport } from "../utils/sse-transport"
18
+ import { expectSessionTitle } from "../utils/waits"
19
+
20
+ const initialPageSize = 20
21
+ const historyPageSize = 200
22
+ const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
23
+ assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
24
+ id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
25
+ parentID: userID,
26
+ created: 1700000001000 + index * 1_000,
27
+ completed: index < initialPageSize,
28
+ }),
29
+ )
30
+ const messages = [userMessage(), ...assistants]
31
+ const lastAssistant = assistants.at(-1)!
32
+ const lastPartID = assistants.at(-1)!.parts[0]!.id
33
+ const userPartID = `prt_${userID}_text`
34
+ const completed = {
35
+ ...lastAssistant.info,
36
+ time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 },
37
+ }
38
+ const scenarios = [
39
+ { name: "completion", info: completed, idleFirst: false, interrupted: false },
40
+ {
41
+ name: "interruption",
42
+ info: { ...completed, error: { name: "MessageAbortedError", data: { message: "Stopped" } } },
43
+ idleFirst: true,
44
+ interrupted: true,
45
+ },
46
+ ] as const
47
+
48
+ test.use({ viewport: { width: 646, height: 1385 } })
49
+
50
+ for (const scenario of scenarios) {
51
+ test(`keeps visible timeline content visible through ${scenario.name}`, async ({ page }) => {
52
+ const requests: { before?: string; phase: "start" | "end" }[] = []
53
+ const pages: { before?: string; limit: number }[] = []
54
+ const roots: { sessionID: string; messageID: string }[] = []
55
+ const sequence: string[] = []
56
+ const history = Promise.withResolvers<void>()
57
+ const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
58
+ server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
59
+ retry: 20,
60
+ })
61
+ await mockOpenCodeServer(page, {
62
+ directory,
63
+ project: project(),
64
+ provider: {
65
+ all: [
66
+ {
67
+ id: "opencode",
68
+ name: "OpenCode",
69
+ models: {
70
+ "claude-opus-4-6": {
71
+ id: "claude-opus-4-6",
72
+ name: "Claude Opus 4.6",
73
+ limit: { context: 200_000 },
74
+ },
75
+ },
76
+ },
77
+ ],
78
+ connected: ["opencode"],
79
+ default: { providerID: "opencode", modelID: "claude-opus-4-6" },
80
+ },
81
+ sessions: [session()],
82
+ sessionStatus: { [sessionID]: { type: "busy" } },
83
+ beforeMessagesResponse: (request) => (request.before ? history.promise : Promise.resolve()),
84
+ onMessages: (request) => {
85
+ requests.push(request)
86
+ sequence.push(`messages:${request.phase}:${request.before ?? "latest"}`)
87
+ },
88
+ onMessage: (request) => {
89
+ roots.push(request)
90
+ sequence.push(`message:${request.messageID}`)
91
+ },
92
+ message: (requestedSessionID, messageID) => {
93
+ if (requestedSessionID !== sessionID) return
94
+ return messages.find((item) => item.info.id === messageID)
95
+ },
96
+ pageMessages: (_, limit, before) => {
97
+ pages.push({ before, limit })
98
+ const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length
99
+ const start = Math.max(0, end - limit)
100
+ return {
101
+ items: messages.slice(start, end),
102
+ cursor: start > 0 ? messages[start]!.info.id : undefined,
103
+ }
104
+ },
105
+ })
106
+ await page.addInitScript(() => {
107
+ const visibleParts = () => {
108
+ const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
109
+ const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
110
+ const view = viewport?.getBoundingClientRect()
111
+ if (!viewport || !view) return []
112
+ return [...viewport.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
113
+ .filter((part) => {
114
+ const rect = part.getBoundingClientRect()
115
+ return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
116
+ })
117
+ .flatMap((part) => (part.dataset.timelinePartId ? [part.dataset.timelinePartId] : []))
118
+ }
119
+ const state = {
120
+ armed: false,
121
+ hidden: false,
122
+ visibleParts: [] as string[],
123
+ samples: 0,
124
+ stop: false,
125
+ arm() {
126
+ state.visibleParts = visibleParts()
127
+ state.armed = true
128
+ },
129
+ }
130
+ ;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
131
+ const sample = () => {
132
+ if (state.armed) {
133
+ const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
134
+ const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
135
+ const view = viewport?.getBoundingClientRect()
136
+ const visible = (partID: string) => {
137
+ const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${CSS.escape(partID)}"]`)
138
+ const rect = part?.getBoundingClientRect()
139
+ return (
140
+ !!rect && !!view && rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
141
+ )
142
+ }
143
+ if (!virtual || state.visibleParts.length === 0 || state.visibleParts.some((partID) => !visible(partID)))
144
+ state.hidden = true
145
+ state.samples++
146
+ }
147
+ if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
148
+ }
149
+ requestAnimationFrame(() => setTimeout(sample, 0))
150
+ })
151
+
152
+ await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
153
+ await transport.waitForConnection()
154
+ await expectSessionTitle(page, title)
155
+ await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible()
156
+ await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
157
+ await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2)
158
+ expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
159
+ expect(sequence.slice(0, 4)).toEqual([
160
+ "messages:start:latest",
161
+ "messages:end:latest",
162
+ `message:${userID}`,
163
+ `messages:start:${messages.at(-initialPageSize)!.info.id}`,
164
+ ])
165
+ await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize)
166
+ await page.evaluate(() => {
167
+ ;(
168
+ window as Window & {
169
+ __historyRootProbe?: { arm(): void }
170
+ }
171
+ ).__historyRootProbe!.arm()
172
+ })
173
+ await waitForProbeSamples(page, 0)
174
+ expect(await visibleContentHidden(page)).toBe(false)
175
+ const beforeHistory = await probeSamples(page)
176
+ history.resolve()
177
+ await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length)
178
+ await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
179
+ await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
180
+ await waitForProbeSamples(page, beforeHistory)
181
+ expect(pages).toEqual([
182
+ { before: undefined, limit: initialPageSize },
183
+ { before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
184
+ ])
185
+ expect(roots).toEqual([{ sessionID, messageID: userID }])
186
+
187
+ const message = messageUpdated(scenario.info)
188
+ const idle = status("idle")
189
+ for (const event of scenario.idleFirst ? [idle, message] : [message, idle]) {
190
+ const beforeEvent = await probeSamples(page)
191
+ await transport.send(event)
192
+ if (event === idle) await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0)
193
+ if (event === message && scenario.interrupted)
194
+ await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
195
+ await waitForProbeSamples(page, beforeEvent)
196
+ const current = await timelineState(page)
197
+ expect(current, JSON.stringify(current)).toMatchObject({ virtual: true })
198
+ expect(current.rows, JSON.stringify(current)).toBeGreaterThan(0)
199
+ }
200
+
201
+ expect(requests[0]).toEqual({ before: undefined, phase: "start", sessionID })
202
+ expect(requests[1]).toEqual({ before: undefined, phase: "end", sessionID })
203
+ await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0)
204
+ await expect(page.locator('[data-timeline-row="bottom-spacer"]')).toBeVisible()
205
+ if (scenario.interrupted) await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
206
+ expect(
207
+ await page.evaluate(() => {
208
+ const state = (window as Window & { __historyRootProbe?: { hidden: boolean; stop: boolean } })
209
+ .__historyRootProbe!
210
+ state.stop = true
211
+ return state.hidden
212
+ }),
213
+ ).toBe(false)
214
+ })
215
+ }
216
+
217
+ function timelineState(page: Page) {
218
+ return page.evaluate(() => ({
219
+ virtual: !!document.querySelector("[data-timeline-virtual-content]"),
220
+ rows: document.querySelectorAll("[data-timeline-key]").length,
221
+ }))
222
+ }
223
+
224
+ function probeSamples(page: Page) {
225
+ return page.evaluate(
226
+ () => (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples,
227
+ )
228
+ }
229
+
230
+ async function waitForProbeSamples(page: Page, after: number) {
231
+ await page.waitForFunction(
232
+ (after) =>
233
+ (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples >= after + 3,
234
+ after,
235
+ )
236
+ }
237
+
238
+ function visibleContentHidden(page: Page) {
239
+ return page.evaluate(
240
+ () => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden,
241
+ )
242
+ }
packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import {
3
+ assistantMessage,
4
+ completedAssistantInfo,
5
+ messageUpdated,
6
+ partUpdated,
7
+ reasoningPart,
8
+ setupTimeline,
9
+ shell,
10
+ status,
11
+ textPart,
12
+ userMessage,
13
+ } from "../performance/timeline-stability/fixture"
14
+
15
+ for (const expanded of [false, true]) {
16
+ test(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ page }) => {
17
+ const id = `prt_shell_default_${expanded}`
18
+ const timeline = await setupTimeline(page, {
19
+ messages: [userMessage(), assistantMessage([shell(id, "completed", lines(3))])],
20
+ settings: { shellToolPartsExpanded: expanded },
21
+ })
22
+ const trigger = page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
23
+ await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
24
+ await trigger.click()
25
+ await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
26
+
27
+ await timeline.send(partUpdated(shell(id, "completed", lines(6))), 180)
28
+ await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")), 180)
29
+ await timeline.send(status("busy"), 100)
30
+ await timeline.send(status("idle"), 250)
31
+ await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
32
+ })
33
+ }
34
+
35
+ test("shows and expands a running shell command without shimmering it", async ({ page }) => {
36
+ const id = "prt_shell_running_command"
37
+ const command = "sleep 10 && echo done"
38
+ await setupTimeline(page, {
39
+ messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })],
40
+ settings: { shellToolPartsExpanded: false },
41
+ })
42
+
43
+ const tool = page.locator(`[data-timeline-part-id="${id}"]`)
44
+ await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
45
+ await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command)
46
+ await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0)
47
+ await tool.locator('[data-slot="collapsible-trigger"]').click()
48
+ await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
49
+ await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
50
+ })
51
+
52
+ test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => {
53
+ const reasoningID = "prt_reasoning_hidden"
54
+ const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
55
+ const timeline = await setupTimeline(page, {
56
+ messages: [userMessage(), assistant],
57
+ settings: { showReasoningSummaries: false },
58
+ cpuRate: 4,
59
+ })
60
+ await timeline.send(status("busy"), 150)
61
+
62
+ await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
63
+ await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
64
+ await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
65
+ await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160)
66
+ await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
67
+ await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180)
68
+ await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100)
69
+ await timeline.send(status("idle"), 300)
70
+ await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
71
+ await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
72
+ })
73
+
74
+ test("moves busy through retry and recovery to final idle content", async ({ page }) => {
75
+ const assistant = assistantMessage([], { completed: false })
76
+ const timeline = await setupTimeline(page, {
77
+ messages: [
78
+ userMessage(undefined, {
79
+ summary: {
80
+ diffs: [
81
+ {
82
+ file: "src/retry.ts",
83
+ additions: 1,
84
+ deletions: 1,
85
+ patch: "@@ -1 +1 @@\n-export const retry = false\n+export const retry = true",
86
+ },
87
+ ],
88
+ },
89
+ }),
90
+ assistant,
91
+ ],
92
+ })
93
+ await timeline.send(status("busy"), 140)
94
+ await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
95
+ await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
96
+ await timeline.send(status("retry"), 180)
97
+ await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
98
+ await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
99
+ await timeline.send(status("busy", 2), 180)
100
+ await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
101
+ await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140)
102
+ await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100)
103
+ await timeline.send(status("idle"), 350)
104
+ await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
105
+ await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
106
+ await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
107
+ })
108
+
109
+ function lines(count: number) {
110
+ return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
111
+ }
packages/app/e2e/regression/session-timeline-locale-projection.spec.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
3
+
4
+ for (const profile of [
5
+ { locale: "de", label: "Erkundung abgeschlossen" },
6
+ { locale: "ar", label: "تم الاستكشاف" },
7
+ ] as const) {
8
+ test(`projects translated context status in ${profile.locale}`, async ({ page }) => {
9
+ const ids = [`prt_locale_${profile.locale}_01_read`, `prt_locale_${profile.locale}_02_glob`]
10
+ await setupTimeline(page, {
11
+ messages: [
12
+ userMessage(),
13
+ assistantMessage([
14
+ toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }),
15
+ toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
16
+ ]),
17
+ ],
18
+ locale: profile.locale,
19
+ })
20
+
21
+ const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
22
+ await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", profile.label)
23
+ await expect(page.locator("html")).toHaveAttribute("lang", profile.locale)
24
+ })
25
+ }
packages/app/e2e/regression/session-timeline-projection.spec.ts ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import {
3
+ assistantMessage,
4
+ setupTimeline,
5
+ status,
6
+ toolPart,
7
+ userMessage,
8
+ userText,
9
+ type PartSeed,
10
+ } from "../performance/timeline-stability/fixture"
11
+
12
+ test.describe("session timeline projection", () => {
13
+ test("renders every admitted tool family and hides timeline-only exclusions", async ({ page }) => {
14
+ const parts = [
15
+ toolPart("prt_01_read", "read", "completed", { filePath: "src/a.ts" }),
16
+ toolPart("prt_02_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
17
+ toolPart("prt_03_grep", "grep", "completed", { path: ".", pattern: "value" }),
18
+ toolPart("prt_04_list", "list", "completed", { path: "src" }),
19
+ toolPart("prt_webfetch", "webfetch", "completed", { url: "https://example.com" }),
20
+ toolPart(
21
+ "prt_websearch",
22
+ "websearch",
23
+ "completed",
24
+ { query: "timeline stability" },
25
+ { output: "https://example.com/result" },
26
+ ),
27
+ toolPart("prt_task", "task", "completed", { description: "Inspect timeline", subagent_type: "explore" }),
28
+ toolPart(
29
+ "prt_bash",
30
+ "bash",
31
+ "completed",
32
+ { command: "printf stable" },
33
+ { output: "stable", title: "printf stable" },
34
+ ),
35
+ editPart("prt_edit"),
36
+ toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
37
+ patchPart("prt_patch"),
38
+ toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
39
+ toolPart(
40
+ "prt_question",
41
+ "question",
42
+ "completed",
43
+ { questions: [{ question: "Keep stable?", header: "Stability", options: [] }] },
44
+ { metadata: { answers: [["Yes"]] } },
45
+ ),
46
+ toolPart("prt_skill", "skill", "completed", { name: "stability" }),
47
+ toolPart("prt_custom", "custom_mcp_tool", "completed", { target: "timeline", count: 2 }),
48
+ ]
49
+ await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
50
+
51
+ await expect(
52
+ page.locator('[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list"]'),
53
+ ).toBeVisible()
54
+ for (const id of [
55
+ "prt_webfetch",
56
+ "prt_websearch",
57
+ "prt_task",
58
+ "prt_bash",
59
+ "prt_edit",
60
+ "prt_write",
61
+ "prt_patch",
62
+ "prt_question",
63
+ "prt_skill",
64
+ "prt_custom",
65
+ ]) {
66
+ await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
67
+ }
68
+ await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
69
+ })
70
+
71
+ test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
72
+ const firstUser = userMessage(
73
+ [
74
+ userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
75
+ id: "prt_comment",
76
+ synthetic: true,
77
+ metadata: {
78
+ opencodeComment: {
79
+ path: "src/a.ts",
80
+ selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 },
81
+ comment: "Keep this stable",
82
+ },
83
+ },
84
+ }),
85
+ userText("Continue after the comment", { id: "prt_visible_user" }),
86
+ ],
87
+ { summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
88
+ )
89
+ const aborted = assistantMessage(
90
+ [
91
+ { id: "prt_before_abort", type: "text", text: "Before interruption" },
92
+ { id: "prt_compaction", type: "compaction", auto: true },
93
+ ],
94
+ {
95
+ id: "msg_1001_assistant_aborted",
96
+ error: { name: "MessageAbortedError", data: { message: "Stopped" } },
97
+ },
98
+ )
99
+ const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], {
100
+ id: "msg_1002_assistant_failed",
101
+ error: {
102
+ name: "APIError",
103
+ data: {
104
+ message: JSON.stringify({ error: { type: "provider_error", message: "Visible provider failure" } }),
105
+ isRetryable: false,
106
+ },
107
+ },
108
+ created: 1700000003000,
109
+ })
110
+ const nextUser = userMessage([userText("Second turn", { id: "prt_second_user" })], {
111
+ id: "msg_2000_second_user",
112
+ created: 1700000005000,
113
+ })
114
+ const nextAssistant = assistantMessage([{ id: "prt_second_text", type: "text", text: "Second response" }], {
115
+ id: "msg_2001_second_assistant",
116
+ parentID: "msg_2000_second_user",
117
+ created: 1700000006000,
118
+ })
119
+ const timeline = await setupTimeline(page, { messages: [firstUser, aborted, failed, nextUser, nextAssistant] })
120
+ await timeline.send(status("idle"), 100)
121
+ const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
122
+ await scroller.evaluate((element) => (element.scrollTop = 0))
123
+
124
+ await expect(page.locator('[data-timeline-row="TurnDivider"]')).toHaveCount(1)
125
+ await expect(page.getByText("Session compacted", { exact: true })).toBeVisible()
126
+ await expect(page.getByText("Visible provider failure")).toBeVisible()
127
+ await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
128
+ await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
129
+ })
130
+
131
+ test("renders inline comments and historical diff summary overflow", async ({ page }) => {
132
+ const user = userMessage(
133
+ [
134
+ userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
135
+ id: "prt_comment_only",
136
+ synthetic: true,
137
+ metadata: {
138
+ opencodeComment: {
139
+ path: "src/a.ts",
140
+ selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 },
141
+ comment: "Keep this stable",
142
+ },
143
+ },
144
+ }),
145
+ userText("Continue after the comment", { id: "prt_comment_visible" }),
146
+ ],
147
+ { summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
148
+ )
149
+ const nextUser = userMessage(undefined, { id: "msg_2000_diff_next_user", created: 1700000010000 })
150
+ const nextAssistant = assistantMessage([], {
151
+ id: "msg_2001_diff_next_assistant",
152
+ parentID: "msg_2000_diff_next_user",
153
+ created: 1700000011000,
154
+ })
155
+ await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] })
156
+ const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
157
+ await scroller.evaluate((element) => (element.scrollTop = 0))
158
+
159
+ await expect(page.getByText("Keep this stable", { exact: true })).toBeVisible()
160
+ await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
161
+ await expect(page.getByText(/show all/i)).toBeVisible()
162
+ })
163
+
164
+ test("renders interruption independently when the turn is not compacted", async ({ page }) => {
165
+ const user = userMessage()
166
+ const before = assistantMessage([{ id: "prt_before", type: "text", text: "Before" }], {
167
+ id: "msg_1001_before",
168
+ error: { name: "MessageAbortedError", data: { message: "Stopped" } },
169
+ })
170
+ const after = assistantMessage([{ id: "prt_after", type: "text", text: "After" }], {
171
+ id: "msg_1002_after",
172
+ created: 1700000003000,
173
+ })
174
+ await setupTimeline(page, { messages: [user, before, after] })
175
+
176
+ await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
177
+ const rows = await page
178
+ .locator('[data-timeline-row="AssistantPart"], [data-timeline-row="TurnDivider"]')
179
+ .evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-row")))
180
+ expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"])
181
+ })
182
+
183
+ test("renders user image, file attachment, file reference, and agent reference", async ({ page }) => {
184
+ const text = "Use @explore with @src/a.ts and inspect the attachments"
185
+ const parts: PartSeed<"user">[] = [
186
+ userText(text, { id: "prt_user_rich" }),
187
+ {
188
+ id: "prt_user_image",
189
+ type: "file",
190
+ mime: "image/png",
191
+ filename: "pixel.png",
192
+ url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
193
+ },
194
+ {
195
+ id: "prt_user_attachment",
196
+ type: "file",
197
+ mime: "application/json",
198
+ filename: "tsconfig.json",
199
+ url: "data:application/json;base64,e30=",
200
+ },
201
+ {
202
+ id: "prt_user_reference",
203
+ type: "file",
204
+ mime: "text/plain",
205
+ filename: "a.ts",
206
+ url: "src/a.ts",
207
+ source: { type: "file", path: "src/a.ts", text: { value: "@src/a.ts", start: 18, end: 27 } },
208
+ },
209
+ {
210
+ id: "prt_user_agent",
211
+ type: "agent",
212
+ name: "explore",
213
+ source: { value: "@explore", start: 4, end: 12 },
214
+ },
215
+ ]
216
+ await setupTimeline(page, { messages: [userMessage(parts), assistantMessage()] })
217
+
218
+ await expect(page.getByAltText("pixel.png")).toBeVisible()
219
+ await expect(page.getByText("tsconfig.json")).toBeVisible()
220
+ await expect(page.getByText("@src/a.ts", { exact: true })).toBeVisible()
221
+ await expect(page.getByText("@explore", { exact: true })).toBeVisible()
222
+ })
223
+ })
224
+
225
+ function editPart(id: string) {
226
+ return toolPart(
227
+ id,
228
+ "edit",
229
+ "completed",
230
+ { filePath: "src/a.ts" },
231
+ {
232
+ metadata: {
233
+ filediff: {
234
+ file: "src/a.ts",
235
+ additions: 1,
236
+ deletions: 1,
237
+ before: "export const value = 1\n",
238
+ after: "export const value = 2\n",
239
+ },
240
+ },
241
+ },
242
+ )
243
+ }
244
+
245
+ function patchPart(id: string) {
246
+ return toolPart(
247
+ id,
248
+ "apply_patch",
249
+ "completed",
250
+ { files: ["src/a.ts", "src/b.ts"] },
251
+ {
252
+ metadata: {
253
+ files: [
254
+ patchFile("src/a.ts", "update"),
255
+ patchFile("src/b.ts", "add"),
256
+ patchFile("src/old.ts", "delete"),
257
+ { ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" },
258
+ ],
259
+ },
260
+ },
261
+ )
262
+ }
263
+
264
+ function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") {
265
+ return {
266
+ filePath,
267
+ relativePath: filePath,
268
+ type,
269
+ additions: type === "delete" ? 0 : 1,
270
+ deletions: type === "add" ? 0 : 1,
271
+ before: type === "add" ? undefined : "export const before = true\n",
272
+ after: type === "delete" ? undefined : "export const after = true\n",
273
+ }
274
+ }
275
+
276
+ function summaryDiff(index: number) {
277
+ return {
278
+ file: `src/diff-${index}.ts`,
279
+ additions: 1,
280
+ deletions: 1,
281
+ patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
282
+ }
283
+ }
packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import {
3
+ assistantMessage,
4
+ reasoningPart,
5
+ setupTimeline,
6
+ status,
7
+ textPart,
8
+ toolPart,
9
+ userMessage,
10
+ } from "../performance/timeline-stability/fixture"
11
+
12
+ const profiles = [
13
+ { name: "summaries off no reasoning", summaries: false, reasoning: "", other: false, thinking: true, body: false },
14
+ {
15
+ name: "summaries off reasoning heading",
16
+ summaries: false,
17
+ reasoning: "## Inspecting stability",
18
+ other: false,
19
+ thinking: true,
20
+ body: false,
21
+ },
22
+ {
23
+ name: "summaries off with visible tool",
24
+ summaries: false,
25
+ reasoning: "## Inspecting stability",
26
+ other: true,
27
+ thinking: true,
28
+ body: false,
29
+ },
30
+ { name: "summaries on no content", summaries: true, reasoning: "", other: false, thinking: true, body: false },
31
+ {
32
+ name: "summaries on blank reasoning",
33
+ summaries: true,
34
+ reasoning: " ",
35
+ other: false,
36
+ thinking: true,
37
+ body: false,
38
+ },
39
+ {
40
+ name: "summaries on visible reasoning",
41
+ summaries: true,
42
+ reasoning: "## Inspecting stability",
43
+ other: false,
44
+ thinking: false,
45
+ body: true,
46
+ },
47
+ {
48
+ name: "summaries on visible tool no reasoning",
49
+ summaries: true,
50
+ reasoning: "",
51
+ other: true,
52
+ thinking: false,
53
+ body: false,
54
+ },
55
+ ] as const
56
+
57
+ for (const profile of profiles) {
58
+ test(`projects busy reasoning profile ${profile.name}`, async ({ page }) => {
59
+ const reasoningID = `prt_reasoning_matrix_${profiles.indexOf(profile)}`
60
+ const parts = [
61
+ ...(profile.reasoning ? [reasoningPart(reasoningID, profile.reasoning)] : []),
62
+ ...(profile.other
63
+ ? [toolPart(`prt_reasoning_tool_${profiles.indexOf(profile)}`, "skill", "running", { name: "inspect" })]
64
+ : []),
65
+ ]
66
+ const timeline = await setupTimeline(page, {
67
+ messages: [userMessage(), assistantMessage(parts, { completed: false })],
68
+ settings: { showReasoningSummaries: profile.summaries },
69
+ })
70
+ await timeline.send(status("busy"), 150)
71
+
72
+ await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0)
73
+ await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(profile.body ? 1 : 0)
74
+ if (!profile.summaries && profile.reasoning.trim()) {
75
+ await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
76
+ }
77
+ })
78
+ }
79
+
80
+ test("does not infer reasoning visibility from provider identity", async ({ page }) => {
81
+ const timeline = await setupTimeline(page, {
82
+ messages: [
83
+ userMessage(),
84
+ assistantMessage([textPart("prt_provider_text", "No reasoning payload")], { completed: false }),
85
+ ],
86
+ settings: { showReasoningSummaries: true },
87
+ })
88
+ await timeline.send(status("busy"), 150)
89
+
90
+ await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
91
+ await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0)
92
+ await expect(page.locator('[data-timeline-part-id="prt_provider_text"]')).toBeVisible()
93
+ })
packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import {
3
+ assistantMessage,
4
+ completedAssistantInfo,
5
+ messageUpdated,
6
+ partUpdated,
7
+ setupTimeline,
8
+ shell,
9
+ status,
10
+ textPart,
11
+ toolPart,
12
+ userMessage,
13
+ } from "../performance/timeline-stability/fixture"
14
+
15
+ test("groups singleton and separated context operations at correct boundaries", async ({ page }) => {
16
+ const parts = [
17
+ toolPart("prt_boundary_01_read", "read", "completed", { filePath: "src/a.ts" }),
18
+ textPart("prt_boundary_02_text", "Boundary text"),
19
+ toolPart("prt_boundary_03_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
20
+ toolPart("prt_boundary_04_grep", "grep", "completed", { path: ".", pattern: "stable" }),
21
+ shell("prt_boundary_05_shell", "completed", "done"),
22
+ toolPart("prt_boundary_06_list", "list", "completed", { path: "src" }),
23
+ ]
24
+ await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
25
+
26
+ await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
27
+ await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible()
28
+ await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible()
29
+ await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5)
30
+ })
31
+
32
+ test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
33
+ const textID = "prt_event_order_text"
34
+ const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
35
+ const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
36
+ await timeline.send(status("busy"), 100)
37
+ await timeline.send(status("idle"), 100)
38
+ await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
39
+ await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 250)
40
+
41
+ await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
42
+ await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("Final after early idle")
43
+ })
packages/app/e2e/regression/session-timeline-shell-outline.spec.ts ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test, type Locator, type Page } from "@playwright/test"
2
+ import {
3
+ assistantMessage,
4
+ setupTimeline,
5
+ shell,
6
+ textPart,
7
+ toolPart,
8
+ userMessage,
9
+ } from "../performance/timeline-stability/fixture"
10
+
11
+ for (const deviceScaleFactor of [1.25, 1.5]) {
12
+ test(`keeps the shell outline inside a fractionally short virtual row at ${deviceScaleFactor}x`, async ({ page }) => {
13
+ const shellID = "prt_shell_outline"
14
+ const timeline = await setupTimeline(page, {
15
+ messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])],
16
+ settings: { newLayoutDesigns: true, shellToolPartsExpanded: true },
17
+ reducedMotion: true,
18
+ deviceScaleFactor,
19
+ })
20
+ const part = page.locator(`[data-timeline-part-id="${shellID}"]`)
21
+ const output = part.locator('[data-component="bash-output"]')
22
+ const row = page.locator("[data-timeline-key]", { has: part })
23
+ await expect(output).toBeVisible()
24
+ await timeline.settle()
25
+
26
+ const geometry = await row.evaluate((element) => {
27
+ const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')
28
+ if (!output) throw new Error("Shell output is unavailable")
29
+ const rowRect = element.getBoundingClientRect()
30
+ const outputRect = output.getBoundingClientRect()
31
+ // Match a rounded-down measurement at a fractional device-pixel phase.
32
+ element.style.height = `${outputRect.bottom - rowRect.top - 0.49}px`
33
+ element.style.transform = "translateY(0.25px)"
34
+ output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)")
35
+ output.style.setProperty("background", "rgb(0, 0, 0)", "important")
36
+ const style = getComputedStyle(output)
37
+ return {
38
+ outputWidth: outputRect.width,
39
+ outputHeight: outputRect.height,
40
+ borderColor: style.borderTopColor,
41
+ boxShadow: style.boxShadow,
42
+ clipMargin: getComputedStyle(element).overflowClipMargin,
43
+ }
44
+ })
45
+ await timeline.settle()
46
+
47
+ const clipped = await row.evaluate((element) => {
48
+ const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')!
49
+ return output.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom
50
+ })
51
+ expect(clipped).toBeCloseTo(0.49, 1)
52
+
53
+ expect(await page.evaluate(() => devicePixelRatio)).toBe(deviceScaleFactor)
54
+ const edges = await captureCardEdges(page, output)
55
+
56
+ expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2)
57
+ expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2)
58
+ expect(geometry.borderColor).toBe("rgb(255, 0, 255)")
59
+ expect(geometry.boxShadow).toBe("none")
60
+ expect(geometry.clipMargin).toBe("0.5px")
61
+ expect(edges.magenta.top).toBeGreaterThan(0.75)
62
+ expect(edges.magenta.bottom).toBeGreaterThan(0.75)
63
+ expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2)
64
+ })
65
+ }
66
+
67
+ test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => {
68
+ const patchID = "prt_patch_outline"
69
+ const file = {
70
+ filePath: "src/outline.ts",
71
+ relativePath: "src/outline.ts",
72
+ type: "update",
73
+ additions: 1,
74
+ deletions: 1,
75
+ before: "const outline = false\n",
76
+ after: "const outline = true\n",
77
+ }
78
+ const timeline = await setupTimeline(page, {
79
+ messages: [
80
+ userMessage(),
81
+ assistantMessage([
82
+ toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }),
83
+ ]),
84
+ ],
85
+ settings: { editToolPartsExpanded: true, newLayoutDesigns: true },
86
+ reducedMotion: true,
87
+ })
88
+ const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
89
+ const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
90
+ const row = page.locator("[data-timeline-key]", { has: part })
91
+ await expect(card).toBeVisible()
92
+ await timeline.settle()
93
+
94
+ const geometry = await row.evaluate((element) => {
95
+ const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
96
+ if (!card) throw new Error("Patch card is unavailable")
97
+ const rowRect = element.getBoundingClientRect()
98
+ const cardRect = card.getBoundingClientRect()
99
+ element.style.height = `${cardRect.bottom - rowRect.top - 0.49}px`
100
+ const clipMargin = getComputedStyle(element).overflowClipMargin
101
+ const bottom = element.getBoundingClientRect().bottom
102
+ return {
103
+ overflow: card.getBoundingClientRect().bottom - bottom,
104
+ paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin),
105
+ clipMargin,
106
+ cardWidth: cardRect.width,
107
+ cardHeight: cardRect.height,
108
+ }
109
+ })
110
+ await timeline.settle()
111
+
112
+ expect(geometry.overflow).toBeCloseTo(0.49, 1)
113
+ expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
114
+ const edges = await captureCardEdges(page, card)
115
+ expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2)
116
+ expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2)
117
+ expect(edges.luminance.top).toBeLessThan(245)
118
+ expect(edges.luminance.bottom).toBeLessThan(245)
119
+ expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10)
120
+ expect(geometry.clipMargin).toBe("0.5px")
121
+ })
122
+
123
+ test("allows paint rounding for every framed row but not fixed turn gaps", async ({ page }) => {
124
+ const secondUserID = "msg_outline_second_user"
125
+ await setupTimeline(page, {
126
+ messages: [
127
+ userMessage(undefined, {
128
+ summary: {
129
+ diffs: [
130
+ {
131
+ file: "src/summary.ts",
132
+ additions: 1,
133
+ deletions: 1,
134
+ patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
135
+ },
136
+ ],
137
+ },
138
+ }),
139
+ assistantMessage([textPart("prt_outline_text", "Assistant text")]),
140
+ userMessage(undefined, { id: secondUserID, created: 1700000010000 }),
141
+ assistantMessage([], {
142
+ id: "msg_outline_second_assistant",
143
+ parentID: secondUserID,
144
+ created: 1700000011000,
145
+ }),
146
+ ],
147
+ })
148
+ await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
149
+ await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
150
+
151
+ const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) =>
152
+ elements.map((element) => ({
153
+ tag: element.querySelector<HTMLElement>("[data-timeline-row]")?.dataset.timelineRow,
154
+ clipMargin: getComputedStyle(element).overflowClipMargin,
155
+ })),
156
+ )
157
+ expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true)
158
+ expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }])
159
+ })
160
+
161
+ async function captureCardEdges(page: Page, card: Locator) {
162
+ const box = await card.boundingBox()
163
+ if (!box) throw new Error("Tool card bounds are unavailable")
164
+ const viewport = page.viewportSize()
165
+ if (!viewport) throw new Error("Viewport bounds are unavailable")
166
+ const screenshot = await page.screenshot()
167
+ return page.evaluate(
168
+ async ({ source, box, viewport }) => {
169
+ const image = new Image()
170
+ image.src = source
171
+ await image.decode()
172
+ const canvas = document.createElement("canvas")
173
+ canvas.width = image.naturalWidth
174
+ canvas.height = image.naturalHeight
175
+ const context = canvas.getContext("2d")
176
+ if (!context) throw new Error("2D canvas is unavailable")
177
+ context.drawImage(image, 0, 0)
178
+ const scale = {
179
+ x: image.naturalWidth / viewport.width,
180
+ y: image.naturalHeight / viewport.height,
181
+ }
182
+ const rows = (candidates: number[]) => {
183
+ const left = Math.floor((box.x + 8) * scale.x)
184
+ const width = Math.floor((box.width - 16) * scale.x)
185
+ return candidates.map((row) => {
186
+ const pixels = context.getImageData(left, row, width, 1).data
187
+ const indexes = Array.from({ length: width }, (_, index) => index * 4)
188
+ return {
189
+ luminance:
190
+ indexes
191
+ .map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3)
192
+ .reduce((sum, value) => sum + value, 0) / width,
193
+ magenta:
194
+ indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200)
195
+ .length / width,
196
+ }
197
+ })
198
+ }
199
+ const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data
200
+ const columns = new Uint32Array(image.naturalWidth)
201
+ for (let index = 0; index < pixels.length; index += 4) {
202
+ if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue
203
+ columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1
204
+ }
205
+ const top = box.y * scale.y
206
+ const bottom = (box.y + box.height) * scale.y
207
+ const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)])
208
+ const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1])
209
+ return {
210
+ box,
211
+ luminance: {
212
+ top: Math.min(...topRows.map((row) => row.luminance)),
213
+ bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance,
214
+ },
215
+ magenta: {
216
+ top: Math.max(...topRows.map((row) => row.magenta)),
217
+ bottom: Math.max(...bottomRows.map((row) => row.magenta)),
218
+ vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length,
219
+ },
220
+ }
221
+ },
222
+ {
223
+ source: `data:image/png;base64,${screenshot.toString("base64")}`,
224
+ viewport,
225
+ box,
226
+ },
227
+ )
228
+ }
packages/app/e2e/regression/session-timeline-tool-projection.spec.ts ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import {
3
+ assistantMessage,
4
+ partUpdated,
5
+ setupTimeline,
6
+ toolPart,
7
+ userMessage,
8
+ } from "../performance/timeline-stability/fixture"
9
+
10
+ test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
11
+ const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
12
+ const parts = ordinary.map((tool, index) =>
13
+ toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
14
+ )
15
+ parts.push(
16
+ toolPart("prt_question_dismissed", "question", "error", questionInput(), {
17
+ error: "The user dismissed this question",
18
+ }),
19
+ toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
20
+ toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
21
+ )
22
+ await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
23
+
24
+ await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
25
+ await expect(page.getByText(/dismissed/i)).toBeVisible()
26
+ await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
27
+ for (let index = 0; index < ordinary.length; index++) {
28
+ await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
29
+ }
30
+ })
31
+
32
+ test("transitions shell and question through running error outcomes", async ({ page }) => {
33
+ const shellID = "prt_transition_error_shell"
34
+ const questionID = "prt_transition_error_question"
35
+ const timeline = await setupTimeline(page, {
36
+ messages: [
37
+ userMessage(),
38
+ assistantMessage(
39
+ [
40
+ toolPart(shellID, "bash", "pending", { command: "exit 1" }),
41
+ toolPart(questionID, "question", "pending", questionInput()),
42
+ ],
43
+ { completed: false },
44
+ ),
45
+ ],
46
+ })
47
+ await timeline.waitForPart(shellID)
48
+ await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
49
+ await timeline.send(partUpdated(toolPart(shellID, "bash", "running", { command: "exit 1" })), 120)
50
+ await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180)
51
+ await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
52
+ await timeline.send(
53
+ partUpdated(toolPart(shellID, "bash", "error", { command: "exit 1" }, { error: "Command exited 1" })),
54
+ 180,
55
+ )
56
+ await timeline.send(
57
+ partUpdated(
58
+ toolPart(questionID, "question", "error", questionInput(), { error: "The user dismissed this question" }),
59
+ ),
60
+ 250,
61
+ )
62
+
63
+ await expect(page.locator(`[data-timeline-part-id="${shellID}"] [data-kind="tool-error-card"]`)).toBeVisible()
64
+ await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i)
65
+ })
66
+
67
+ test("labels all web search provider variants", async ({ page }) => {
68
+ const parts = [
69
+ toolPart(
70
+ "prt_search_parallel",
71
+ "websearch",
72
+ "completed",
73
+ { query: "parallel" },
74
+ { metadata: { provider: "parallel" } },
75
+ ),
76
+ toolPart("prt_search_exa", "websearch", "completed", { query: "exa" }, { metadata: { provider: "exa" } }),
77
+ toolPart("prt_search_generic", "websearch", "completed", { query: "generic" }),
78
+ ]
79
+ await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
80
+
81
+ await expect(page.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
82
+ await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
83
+ await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible()
84
+ })
85
+
86
+ function questionInput() {
87
+ return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
88
+ }
89
+
90
+ function errorInput(tool: string) {
91
+ if (tool === "bash") return { command: "exit 1" }
92
+ if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
93
+ if (tool === "apply_patch") return { files: ["src/error.ts"] }
94
+ if (tool === "webfetch") return { url: "https://example.com" }
95
+ if (tool === "websearch") return { query: "failure" }
96
+ if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
97
+ if (tool === "skill") return { name: "failure" }
98
+ return { target: "failure" }
99
+ }
packages/app/e2e/regression/session-timeline-tool-state.spec.ts ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import {
3
+ assistantMessage,
4
+ partUpdated,
5
+ setupTimeline,
6
+ toolPart,
7
+ userMessage,
8
+ } from "../performance/timeline-stability/fixture"
9
+
10
+ test("updates expanded web search links without resetting expansion", async ({ page }) => {
11
+ const searchID = "prt_websearch_mutation"
12
+ const input = { query: "timeline stability" }
13
+ const timeline = await setupTimeline(page, {
14
+ messages: [
15
+ userMessage(),
16
+ assistantMessage([toolPart(searchID, "websearch", "completed", input, { output: "https://example.com/one" })]),
17
+ ],
18
+ })
19
+ const wrapper = page.locator(`[data-timeline-part-id="${searchID}"]`)
20
+ const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
21
+ await trigger.click()
22
+ await expect(trigger).toHaveAttribute("aria-expanded", "true")
23
+ await timeline.send(
24
+ partUpdated(
25
+ toolPart(searchID, "websearch", "completed", input, {
26
+ output: "https://example.com/one\nhttps://example.com/two",
27
+ }),
28
+ ),
29
+ 300,
30
+ )
31
+ await expect(trigger).toHaveAttribute("aria-expanded", "true")
32
+ await expect(wrapper.locator('a[href="https://example.com/two"]')).toBeVisible()
33
+ })
34
+
35
+ test("preserves an expanded tool error card across duplicate delivery", async ({ page }) => {
36
+ const toolID = "prt_duplicate_error"
37
+ const failed = toolPart(toolID, "bash", "error", { command: "exit 1" }, { error: "Command failed visibly" })
38
+ const timeline = await setupTimeline(page, { messages: [userMessage(), assistantMessage([failed])] })
39
+ const wrapper = page.locator(`[data-timeline-part-id="${toolID}"]`)
40
+ const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
41
+ await trigger.click()
42
+ await expect(trigger).toHaveAttribute("aria-expanded", "true")
43
+ await timeline.send(partUpdated(failed), 150)
44
+ await timeline.send(partUpdated(failed), 250)
45
+ await expect(trigger).toHaveAttribute("aria-expanded", "true")
46
+ await expect(wrapper).toContainText("Command failed visibly")
47
+ })
48
+
49
+ test("renders multiple question answers and preserves open state on answer updates", async ({ page }) => {
50
+ const questionID = "prt_multi_question"
51
+ const input = {
52
+ questions: [
53
+ { header: "First", question: "First choice?", options: [] },
54
+ { header: "Second", question: "Second choice?", options: [], multiple: true },
55
+ ],
56
+ }
57
+ const timeline = await setupTimeline(page, {
58
+ messages: [
59
+ userMessage(),
60
+ assistantMessage([
61
+ toolPart(questionID, "question", "completed", input, { metadata: { answers: [["A"], ["B", "C"]] } }),
62
+ ]),
63
+ ],
64
+ })
65
+ const wrapper = page.locator(`[data-timeline-part-id="${questionID}"]`)
66
+ const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
67
+ await expect(trigger).toHaveAttribute("aria-expanded", "true")
68
+ await timeline.send(
69
+ partUpdated(
70
+ toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Updated"], ["B", "C"]] } }),
71
+ ),
72
+ 300,
73
+ )
74
+ await expect(trigger).toHaveAttribute("aria-expanded", "true")
75
+ await expect(wrapper).toContainText("Updated")
76
+ await expect(wrapper).toContainText("B, C")
77
+ })
packages/app/e2e/regression/session-timeline-transport.spec.ts ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test, type Page } from "@playwright/test"
2
+ import {
3
+ assistantMessage,
4
+ partUpdated,
5
+ setupTimeline,
6
+ textPart,
7
+ userMessage,
8
+ } from "../performance/timeline-stability/fixture"
9
+
10
+ test("keeps one connection open while delivering multiple events", async ({ page }) => {
11
+ const timeline = await setupTimeline(page)
12
+
13
+ const first = await timeline.transport.send(partUpdated(textPart("prt_transport_first", "first event")))
14
+ const second = await timeline.transport.send(partUpdated(textPart("prt_transport_second", "second event")))
15
+
16
+ await timeline.waitForPart("prt_transport_first")
17
+ await timeline.waitForPart("prt_transport_second")
18
+ expect(first.connectionID).toBe(second.connectionID)
19
+ await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
20
+ expect(await timeline.transport.acknowledgements()).toHaveLength(2)
21
+ })
22
+
23
+ test("delivers a burst from one stream chunk", async ({ page }) => {
24
+ const timeline = await setupTimeline(page)
25
+ const acknowledgements = await timeline.transport.burst([
26
+ partUpdated(textPart("prt_transport_burst_a", "burst a")),
27
+ partUpdated(textPart("prt_transport_burst_b", "burst b")),
28
+ ])
29
+
30
+ await timeline.waitForPart("prt_transport_burst_a")
31
+ await timeline.waitForPart("prt_transport_burst_b")
32
+ expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1])
33
+ expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(2)
34
+ })
35
+
36
+ test("parses split JSON and a split multibyte code point", async ({ page }) => {
37
+ const timeline = await setupTimeline(page)
38
+ const payload = partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603"))
39
+ const encoded = new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`)
40
+ const snowman = new TextEncoder().encode("\u2603")[0]!
41
+ const multibyte = encoded.indexOf(snowman)
42
+
43
+ const acknowledgement = await timeline.transport.split(payload, [9, multibyte + 1, multibyte + 2])
44
+
45
+ await timeline.waitForPart("prt_transport_split")
46
+ await expect(page.locator('[data-timeline-part-id="prt_transport_split"]')).toContainText(
47
+ "split snowman \u2603\u2603\u2603",
48
+ )
49
+ expect(acknowledgement.chunkCount).toBe(4)
50
+ })
51
+
52
+ test("delivers server heartbeat without mutating the timeline", async ({ page }) => {
53
+ const sentinelID = "prt_transport_heartbeat_sentinel"
54
+ const timeline = await setupTimeline(page, {
55
+ messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])],
56
+ })
57
+ await timeline.waitForPart("prt_transport_steady")
58
+ const before = await stableTimelineRows(page)
59
+
60
+ await timeline.transport.writeRaw(": heartbeat\n\n")
61
+ await timeline.transport.send(partUpdated(textPart(sentinelID, "heartbeat processed")))
62
+ await timeline.waitForPart(sentinelID)
63
+
64
+ await expect
65
+ .poll(async () => {
66
+ const rows = await timelineRows(page)
67
+ return rows.filter((row) => before.some((item) => item.key === row.key))
68
+ })
69
+ .toEqual(before)
70
+ await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
71
+ })
72
+
73
+ test("reconnects after a clean close", async ({ page }) => {
74
+ const timeline = await setupTimeline(page)
75
+ const first = await timeline.transport.waitForConnection()
76
+
77
+ await timeline.transport.close()
78
+ const second = await timeline.transport.waitForConnection({ after: first.id })
79
+ await timeline.transport.send(partUpdated(textPart("prt_transport_close", "after close")))
80
+
81
+ await timeline.waitForPart("prt_transport_close")
82
+ expect(second.id).toBeGreaterThan(first.id)
83
+ expect((await timeline.transport.connections())[0]?.endedBy).toBe("close")
84
+ })
85
+
86
+ test("reconnects after a stream error", async ({ page }) => {
87
+ const timeline = await setupTimeline(page)
88
+ const first = await timeline.transport.waitForConnection()
89
+
90
+ await timeline.transport.error("contract failure")
91
+ const second = await timeline.transport.waitForConnection({ after: first.id })
92
+ await timeline.transport.send(partUpdated(textPart("prt_transport_error", "after error")))
93
+
94
+ await timeline.waitForPart("prt_transport_error")
95
+ await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2)
96
+ expect(second.id).toBeGreaterThan(first.id)
97
+ expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
98
+ })
99
+
100
+ test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
101
+ const timeline = await setupTimeline(page, { protocol: "v2" })
102
+ const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
103
+ id: "timeline-event-7",
104
+ })
105
+ await timeline.waitForPart("prt_transport_id")
106
+
107
+ await timeline.transport.error("retry with event id")
108
+ const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
109
+
110
+ expect(first.eventID).toBe("timeline-event-7")
111
+ expect(connection.headers["last-event-id"]).toBeUndefined()
112
+ })
113
+
114
+ test("passes through non-event fetches", async ({ page }) => {
115
+ const timeline = await setupTimeline(page)
116
+
117
+ const health = await page.evaluate(async () => {
118
+ const response = await fetch("/global/health")
119
+ return response.json()
120
+ })
121
+
122
+ expect(health).toEqual({ healthy: true })
123
+ await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
124
+ })
125
+
126
+ async function stableTimelineRows(page: Page) {
127
+ let previous: Awaited<ReturnType<typeof timelineRows>> | undefined
128
+ let stable = 0
129
+ await expect
130
+ .poll(
131
+ async () => {
132
+ const next = await timelineRows(page)
133
+ stable = JSON.stringify(next) === JSON.stringify(previous) ? stable + 1 : 0
134
+ previous = next
135
+ return stable
136
+ },
137
+ { intervals: [50, 50, 100] },
138
+ )
139
+ .toBeGreaterThanOrEqual(2)
140
+ return previous!
141
+ }
142
+
143
+ function timelineRows(page: Page) {
144
+ return page.locator("[data-timeline-key]").evaluateAll((elements) =>
145
+ elements.map((element) => ({
146
+ key: element.getAttribute("data-timeline-key"),
147
+ row: element.querySelector("[data-timeline-row]")?.getAttribute("data-timeline-row"),
148
+ parts: Array.from(element.querySelectorAll("[data-timeline-part-id]"), (part) =>
149
+ part.getAttribute("data-timeline-part-id"),
150
+ ),
151
+ text: element.textContent,
152
+ })),
153
+ )
154
+ }
packages/app/e2e/regression/session-todo-dock-navigation.spec.ts ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { base64Encode } from "@opencode-ai/core/util/encode"
2
+ import { expect, test, type Page } from "@playwright/test"
3
+ import { mockOpenCodeServer } from "../utils/mock-server"
4
+ import { expectSessionTitle } from "../utils/waits"
5
+
6
+ const directory = "C:/OpenCode/TodoDockNavigation"
7
+ const projectID = "proj_todo_dock_navigation"
8
+ const sourceID = "ses_todo_dock_source"
9
+ const otherID = "ses_todo_dock_other"
10
+ const sourceTitle = "Todo dock animation"
11
+ const otherTitle = "Separate session"
12
+
13
+ const activeTodos = [
14
+ { id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
15
+ { id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
16
+ { id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
17
+ ]
18
+
19
+ type EventPayload = {
20
+ directory: string
21
+ payload: Record<string, unknown>
22
+ }
23
+
24
+ test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
25
+
26
+ test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => {
27
+ test.setTimeout(90_000)
28
+ const events: EventPayload[] = []
29
+ const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
30
+ const sessionStatus: Record<string, { type: "busy" | "idle" }> = {}
31
+
32
+ await mockOpenCodeServer(page, {
33
+ directory,
34
+ project: {
35
+ id: projectID,
36
+ worktree: directory,
37
+ vcs: "git",
38
+ name: "todo-dock-navigation",
39
+ time: { created: 1700000000000, updated: 1700000000000 },
40
+ sandboxes: [],
41
+ },
42
+ provider: {
43
+ all: [
44
+ {
45
+ id: "opencode",
46
+ name: "OpenCode",
47
+ models: {
48
+ "claude-opus-4-6": {
49
+ id: "claude-opus-4-6",
50
+ name: "Claude Opus 4.6",
51
+ limit: { context: 200_000 },
52
+ },
53
+ },
54
+ },
55
+ ],
56
+ connected: ["opencode"],
57
+ default: { providerID: "opencode", modelID: "claude-opus-4-6" },
58
+ },
59
+ sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
60
+ sessionStatus: { [sourceID]: { type: "busy" } },
61
+ pageMessages: () => ({ items: [] }),
62
+ events: () => events.splice(0, 1),
63
+ eventRetry: 16,
64
+ sessionStatus: () => sessionStatus,
65
+ todos: (sessionID) => todos[sessionID] ?? [],
66
+ })
67
+ await configurePage(page)
68
+
69
+ await page.goto(sessionHref(sourceID))
70
+ await expectSessionTitle(page, sourceTitle)
71
+ const dock = page.locator('[data-component="session-todo-dock"]')
72
+ await expect(dock).toHaveCount(0)
73
+
74
+ sessionStatus[sourceID] = { type: "busy" }
75
+ events.push(statusEvent(sourceID, "busy"))
76
+ await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
77
+
78
+ await page.waitForTimeout(700)
79
+ const opening = sampleDock(page, 1_000)
80
+ todos[sourceID] = activeTodos
81
+ events.push(todoEvent(sourceID, activeTodos))
82
+ await expect(dock).toBeVisible()
83
+ await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
84
+ expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
85
+
86
+ await switchSession(page, otherID, otherTitle)
87
+ await expect(dock).toHaveCount(0)
88
+
89
+ const returningOpen = sampleDock(page, 700)
90
+ await switchSession(page, sourceID, sourceTitle)
91
+ const openSamples = (await returningOpen).filter((sample) => sample.present)
92
+ expect(openSamples.length).toBeGreaterThan(0)
93
+ expect(openSamples[0]!.opacity).toBeGreaterThan(0.98)
94
+ expect(openSamples[0]!.height).toBeGreaterThan(70)
95
+ await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
96
+
97
+ const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" }))
98
+ const closing = sampleDock(page, 1_000)
99
+ todos[sourceID] = completedTodos
100
+ events.push(todoEvent(sourceID, completedTodos))
101
+ await expect(dock).toHaveCount(0)
102
+ expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
103
+ todos[sourceID] = []
104
+ events.push(todoEvent(sourceID, []))
105
+
106
+ await switchSession(page, otherID, otherTitle)
107
+ const returningEmpty = sampleDock(page, 700)
108
+ await switchSession(page, sourceID, sourceTitle)
109
+ await expect(dock).toHaveCount(0)
110
+ expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
111
+ })
112
+
113
+ function session(id: string, title: string, created: number) {
114
+ return {
115
+ id,
116
+ slug: id,
117
+ projectID,
118
+ directory,
119
+ title,
120
+ version: "dev",
121
+ time: { created, updated: created },
122
+ }
123
+ }
124
+
125
+ function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload {
126
+ return {
127
+ directory,
128
+ payload: { type: "session.status", properties: { sessionID, status: { type } } },
129
+ }
130
+ }
131
+
132
+ function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload {
133
+ return {
134
+ directory,
135
+ payload: { type: "todo.updated", properties: { sessionID, todos: next } },
136
+ }
137
+ }
138
+
139
+ async function configurePage(page: Page) {
140
+ const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
141
+ await page.addInitScript(
142
+ ({ directory, dirBase64, server, sessionIDs }) => {
143
+ localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
144
+ localStorage.setItem(
145
+ "opencode.global.dat:server",
146
+ JSON.stringify({
147
+ projects: { local: [{ worktree: directory, expanded: true }] },
148
+ lastProject: { local: directory },
149
+ }),
150
+ )
151
+ localStorage.setItem(
152
+ "opencode.window.browser.dat:tabs",
153
+ JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
154
+ )
155
+ },
156
+ { directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] },
157
+ )
158
+ }
159
+
160
+ function sessionHref(sessionID: string) {
161
+ const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
162
+ return `/server/${base64Encode(server)}/session/${sessionID}`
163
+ }
164
+
165
+ async function switchSession(page: Page, sessionID: string, title: string) {
166
+ const href = sessionHref(sessionID)
167
+ const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
168
+ await expect(tab).toBeVisible()
169
+ await tab.click()
170
+ await expectSessionTitle(page, title)
171
+ }
172
+
173
+ function sampleDock(page: Page, duration: number) {
174
+ return page.evaluate(async (duration) => {
175
+ const samples: { present: boolean; height: number; opacity: number }[] = []
176
+ const start = performance.now()
177
+ while (performance.now() - start < duration) {
178
+ const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
179
+ const clip = dock?.parentElement?.parentElement
180
+ const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
181
+ samples.push({
182
+ present: !!dock,
183
+ height: clip?.getBoundingClientRect().height ?? 0,
184
+ opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
185
+ })
186
+ await new Promise(requestAnimationFrame)
187
+ }
188
+ return samples
189
+ }, duration)
190
+ }
packages/app/e2e/regression/subagent-child-navigation.spec.ts ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { base64Encode } from "@opencode-ai/core/util/encode"
2
+ import { expect, test, type Page } from "@playwright/test"
3
+ import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
4
+ import { expectSessionTitle } from "../utils/waits"
5
+
6
+ const directory = "C:/OpenCode/SubagentNavigation"
7
+ const projectID = "proj_subagent_navigation"
8
+ const parentID = "ses_subagent_parent"
9
+ const childID = "ses_subagent_child"
10
+ const parentTitle = "Parent session"
11
+ const childTitle = "Subagent child session"
12
+ // Child session pages derive their heading from the task part that spawned them.
13
+ const taskDescription = "Inspect child navigation"
14
+
15
+ type EventPayload = { directory: string; payload: Record<string, unknown> }
16
+
17
+ test.use({ viewport: { width: 1440, height: 900 } })
18
+
19
+ test("navigates to a subagent child session missing from the session list", async ({ page }) => {
20
+ await setup(page)
21
+ await openChildFromParent(page)
22
+
23
+ await expectSessionTitle(page, taskDescription)
24
+ await expect(page.getByRole("heading", { name: parentTitle })).toHaveCount(0)
25
+
26
+ const titlebarRight = page.locator("#opencode-titlebar-right")
27
+ await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
28
+ })
29
+
30
+ test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
31
+ const events: EventPayload[] = []
32
+ await setup(page, () => events.splice(0, 1))
33
+ await openChildFromParent(page)
34
+ await expectSessionTitle(page, taskDescription)
35
+
36
+ events.push({
37
+ directory,
38
+ payload: { type: "session.deleted", properties: { info: childSession() } },
39
+ })
40
+
41
+ await expect(page.getByText("This session cannot be found")).toBeVisible()
42
+ await expect(page.getByRole("button", { name: "Close Tab", exact: true })).toBeVisible()
43
+ await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0)
44
+ })
45
+
46
+ async function setup(page: Page, events?: () => EventPayload[]) {
47
+ await mockOpenCodeServer(page, {
48
+ directory,
49
+ project: {
50
+ id: projectID,
51
+ worktree: directory,
52
+ vcs: "git",
53
+ name: "subagent-navigation",
54
+ time: { created: 1700000000000, updated: 1700000000000 },
55
+ sandboxes: [],
56
+ },
57
+ provider: {
58
+ all: [
59
+ {
60
+ id: "opencode",
61
+ name: "OpenCode",
62
+ models: {
63
+ "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } },
64
+ },
65
+ },
66
+ ],
67
+ connected: ["opencode"],
68
+ default: { providerID: "opencode", modelID: "claude-opus-4-6" },
69
+ },
70
+ sessions: [session(parentID, parentTitle, 1700000000000), childSession()],
71
+ pageMessages: (sessionID) => ({ items: sessionID === parentID ? parentMessages() : [] }),
72
+ events,
73
+ eventRetry: events ? 16 : undefined,
74
+ })
75
+ // The child session resolves by ID but is absent from the session list,
76
+ // matching a subagent session that has not been loaded into the list cache yet.
77
+ await page.route(
78
+ (url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
79
+ (route) =>
80
+ route.fulfill({
81
+ status: 200,
82
+ contentType: "application/json",
83
+ headers: { "access-control-allow-origin": "*" },
84
+ body: JSON.stringify({
85
+ data: [currentSession(session(parentID, parentTitle, 1700000000000))],
86
+ cursor: {},
87
+ }),
88
+ }),
89
+ )
90
+ await configurePage(page)
91
+ }
92
+
93
+ async function openChildFromParent(page: Page) {
94
+ await page.goto(sessionHref(parentID))
95
+ await expectSessionTitle(page, parentTitle)
96
+
97
+ const card = page.locator(`a[href="${sessionHref(childID)}"]`)
98
+ await expect(card).toBeVisible()
99
+ await card.click()
100
+
101
+ await expect(page).toHaveURL(new RegExp(`/server/.+/session/${childID}$`), { timeout: 15_000 })
102
+ }
103
+
104
+ function session(id: string, title: string, created: number, extra?: Record<string, unknown>) {
105
+ return {
106
+ id,
107
+ slug: id,
108
+ projectID,
109
+ directory,
110
+ title,
111
+ version: "dev",
112
+ time: { created, updated: created },
113
+ ...extra,
114
+ }
115
+ }
116
+
117
+ function childSession() {
118
+ return session(childID, childTitle, 1700000001000, { parentID })
119
+ }
120
+
121
+ function parentMessages() {
122
+ const userID = "msg_user_0001"
123
+ const assistantID = "msg_assistant_0001"
124
+ return [
125
+ {
126
+ info: {
127
+ id: userID,
128
+ sessionID: parentID,
129
+ role: "user",
130
+ time: { created: 1700000000000 },
131
+ agent: "build",
132
+ model: { providerID: "opencode", modelID: "claude-opus-4-6" },
133
+ },
134
+ parts: [
135
+ {
136
+ id: "prt_user_text_0001",
137
+ sessionID: parentID,
138
+ messageID: userID,
139
+ type: "text",
140
+ text: "Delegate work to a subagent",
141
+ },
142
+ ],
143
+ },
144
+ {
145
+ info: {
146
+ id: assistantID,
147
+ sessionID: parentID,
148
+ role: "assistant",
149
+ time: { created: 1700000001000, completed: 1700000002000 },
150
+ parentID: userID,
151
+ modelID: "claude-opus-4-6",
152
+ providerID: "opencode",
153
+ mode: "build",
154
+ agent: "build",
155
+ path: { cwd: directory, root: directory },
156
+ cost: 0.01,
157
+ tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
158
+ finish: "stop",
159
+ },
160
+ parts: [
161
+ {
162
+ id: "prt_tool_task_0001",
163
+ sessionID: parentID,
164
+ messageID: assistantID,
165
+ type: "tool",
166
+ callID: "call_task_0001",
167
+ tool: "task",
168
+ state: {
169
+ status: "completed",
170
+ input: { description: taskDescription, subagent_type: "explore" },
171
+ output: "Subagent finished",
172
+ title: taskDescription,
173
+ metadata: { sessionId: childID },
174
+ time: { start: 1700000001000, end: 1700000002000 },
175
+ },
176
+ },
177
+ ],
178
+ },
179
+ ]
180
+ }
181
+
182
+ async function configurePage(page: Page) {
183
+ const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
184
+ await page.addInitScript(
185
+ ({ directory, server, sessionId }) => {
186
+ localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
187
+ localStorage.setItem(
188
+ "opencode.global.dat:server",
189
+ JSON.stringify({
190
+ projects: { local: [{ worktree: directory, expanded: true }] },
191
+ lastProject: { local: directory },
192
+ }),
193
+ )
194
+ localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify([{ type: "session", server, sessionId }]))
195
+ },
196
+ { directory, server, sessionId: parentID },
197
+ )
198
+ }
199
+
200
+ function sessionHref(sessionID: string) {
201
+ const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
202
+ return `/server/${base64Encode(server)}/session/${sessionID}`
203
+ }
packages/app/e2e/regression/tab-navigate-mousedown.spec.ts ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test, type Page, type Route } from "@playwright/test"
2
+ import { base64Encode } from "@opencode-ai/core/util/encode"
3
+ import { currentSession } from "../utils/mock-server"
4
+
5
+ const server = "http://127.0.0.1:4096"
6
+ const sessionA = session("ses_tab_a", "Tab A session")
7
+ const sessionB = session("ses_tab_b", "Tab B session")
8
+ const sessionC = session("ses_tab_c", "Tab C session")
9
+ const unresolvedSessionID = "ses_tab_unresolved"
10
+
11
+ test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
12
+ await mockServer(page)
13
+ await page.addInitScript(
14
+ ({ server, sessionA, sessionB }) => {
15
+ localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
16
+ localStorage.setItem(
17
+ "opencode.window.browser.dat:tabs",
18
+ JSON.stringify([
19
+ { type: "session", server, sessionId: sessionA },
20
+ { type: "session", server, sessionId: sessionB },
21
+ ]),
22
+ )
23
+ },
24
+ { server, sessionA: sessionA.id, sessionB: sessionB.id },
25
+ )
26
+
27
+ const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
28
+ const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
29
+ await page.goto(hrefA)
30
+ await expect(page.getByText(sessionA.title).first()).toBeVisible()
31
+
32
+ const linkB = page.locator(`a[data-titlebar-tab-link][href="${hrefB}"]`)
33
+ await expect(linkB).toBeVisible()
34
+ const box = await linkB.boundingBox()
35
+ if (!box) throw new Error("tab link has no bounding box")
36
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
37
+ await page.mouse.down()
38
+
39
+ // Navigation must happen on mousedown, before the button is released.
40
+ await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
41
+ await page.mouse.up()
42
+ await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
43
+ })
44
+
45
+ test("keyboard navigation follows the visible tab order", async ({ page }) => {
46
+ await mockServer(page)
47
+ await page.addInitScript(
48
+ ({ server, sessionA, unresolved, sessionC }) => {
49
+ localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
50
+ localStorage.setItem(
51
+ "opencode.window.browser.dat:tabs",
52
+ JSON.stringify([
53
+ { type: "session", server, sessionId: sessionA },
54
+ { type: "session", server, sessionId: unresolved },
55
+ { type: "session", server, sessionId: sessionC },
56
+ ]),
57
+ )
58
+ },
59
+ { server, sessionA: sessionA.id, unresolved: unresolvedSessionID, sessionC: sessionC.id },
60
+ )
61
+
62
+ const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
63
+ const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}`
64
+ await page.goto(hrefA)
65
+ await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(2)
66
+ await expect(page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefC}"])`)).toBeVisible()
67
+
68
+ await page.keyboard.press("Control+Alt+ArrowRight")
69
+
70
+ await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
71
+ })
72
+
73
+ function session(id: string, title: string) {
74
+ return {
75
+ id,
76
+ slug: id,
77
+ projectID: "project-tabs",
78
+ directory: "C:/tab-project",
79
+ title,
80
+ version: "dev",
81
+ time: { created: 1, updated: 1 },
82
+ }
83
+ }
84
+
85
+ async function mockServer(page: Page) {
86
+ const sessions = [sessionA, sessionB, sessionC]
87
+ await page.route("**/*", async (route) => {
88
+ const url = new URL(route.request().url())
89
+ if (url.origin !== server) return route.fallback()
90
+ if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
91
+ return new Promise(() => {})
92
+ if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
93
+ return sse(route)
94
+ if (url.pathname === "/global/health") return json(route, { healthy: true })
95
+ if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
96
+ if (url.pathname === "/api/session/active") return json(route, { data: {} })
97
+ const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
98
+ if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
99
+ if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
100
+ return json(route, { data: [], cursor: {} })
101
+ const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
102
+ if (byId) return json(route, byId)
103
+ if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
104
+ if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
105
+ if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
106
+ if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
107
+ return json(route, [])
108
+ if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
109
+ if (url.pathname === "/provider")
110
+ return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
111
+ if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
112
+ if (url.pathname === "/project" || url.pathname === "/project/current") {
113
+ const project = {
114
+ id: sessionA.projectID,
115
+ worktree: sessionA.directory,
116
+ vcs: "git",
117
+ time: { created: 1, updated: 1 },
118
+ sandboxes: [],
119
+ }
120
+ return json(route, url.pathname === "/project" ? [project] : project)
121
+ }
122
+ if (url.pathname === "/path")
123
+ return json(route, {
124
+ state: sessionA.directory,
125
+ config: sessionA.directory,
126
+ worktree: sessionA.directory,
127
+ directory: sessionA.directory,
128
+ home: sessionA.directory,
129
+ })
130
+ if (url.pathname === "/api/path")
131
+ return json(route, {
132
+ state: sessionA.directory,
133
+ config: sessionA.directory,
134
+ worktree: sessionA.directory,
135
+ directory: sessionA.directory,
136
+ home: sessionA.directory,
137
+ })
138
+ if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
139
+ if (url.pathname === "/api/vcs")
140
+ return json(route, {
141
+ location: { directory: sessionA.directory },
142
+ data: { branch: "main", defaultBranch: "main" },
143
+ })
144
+ return json(route, {})
145
+ })
146
+ }
147
+
148
+ function json(route: Route, body: unknown, status = 200) {
149
+ return route.fulfill({
150
+ status,
151
+ contentType: "application/json",
152
+ headers: { "access-control-allow-origin": "*" },
153
+ body: JSON.stringify(body),
154
+ })
155
+ }
156
+
157
+ function sse(route: Route) {
158
+ return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
159
+ }
packages/app/e2e/regression/terminal-composer-focus.spec.ts ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { base64Encode } from "@opencode-ai/core/util/encode"
2
+ import { expect, test, type Page } from "@playwright/test"
3
+ import { mockOpenCodeServer } from "../utils/mock-server"
4
+ import { expectSessionTitle } from "../utils/waits"
5
+
6
+ const directory = "C:/OpenCode/TerminalComposerFocus"
7
+ const projectID = "proj_terminal_composer_focus"
8
+ const sessionID = "ses_terminal_composer_focus"
9
+ const ptyID = "pty_terminal_composer_focus"
10
+ const newPtyID = "pty_terminal_composer_focus_new"
11
+
12
+ test.use({ viewport: { width: 1440, height: 900 } })
13
+
14
+ test.beforeEach(async ({ page }) => {
15
+ await mockOpenCodeServer(page, {
16
+ protocol: "v2",
17
+ directory,
18
+ project: {
19
+ id: projectID,
20
+ worktree: directory,
21
+ vcs: "git",
22
+ name: "terminal-composer-focus",
23
+ time: { created: 1700000000000, updated: 1700000000000 },
24
+ sandboxes: [],
25
+ },
26
+ provider: {
27
+ all: [
28
+ {
29
+ id: "opencode",
30
+ name: "OpenCode",
31
+ models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
32
+ },
33
+ ],
34
+ connected: ["opencode"],
35
+ default: { providerID: "opencode", modelID: "test" },
36
+ },
37
+ sessions: [
38
+ {
39
+ id: sessionID,
40
+ slug: "terminal-composer-focus",
41
+ projectID,
42
+ directory,
43
+ title: "Terminal composer focus",
44
+ version: "dev",
45
+ time: { created: 1700000000000, updated: 1700000000000 },
46
+ },
47
+ ],
48
+ pageMessages: () => ({ items: [] }),
49
+ })
50
+ await page.route("**/api/pty*", (route) => {
51
+ expect(new URL(route.request().url()).searchParams.get("location[directory]")).toBe(directory)
52
+ return route.fulfill({
53
+ status: 200,
54
+ contentType: "application/json",
55
+ body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
56
+ })
57
+ })
58
+ await page.route(`**/api/pty/${ptyID}*`, (route) =>
59
+ route.fulfill({
60
+ status: 200,
61
+ contentType: "application/json",
62
+ body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
63
+ }),
64
+ )
65
+ await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) =>
66
+ route.fulfill({
67
+ status: 200,
68
+ contentType: "application/json",
69
+ headers: { "access-control-allow-origin": "*" },
70
+ body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
71
+ }),
72
+ )
73
+ await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined)
74
+ await page.addInitScript(() => {
75
+ localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
76
+ })
77
+ })
78
+
79
+ test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
80
+ await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
81
+ await expectSessionTitle(page, "Terminal composer focus")
82
+
83
+ const composer = page.locator('[data-component="prompt-input"]')
84
+ const terminal = page.locator('[data-component="terminal"]')
85
+ await page.keyboard.press("Control+Backquote")
86
+ await expect(terminal).toBeVisible()
87
+ await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
88
+
89
+ await page.keyboard.type("x")
90
+ await expect(composer).toHaveText("")
91
+
92
+ await page.waitForTimeout(300)
93
+ await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
94
+ await page.keyboard.type("a")
95
+
96
+ await expect(composer).toBeFocused()
97
+ await expect(composer).toHaveText("a")
98
+ })
99
+
100
+ test("keeps composer focus when a cached terminal finishes mounting", async ({ page }) => {
101
+ const ghostty = Promise.withResolvers<void>()
102
+ const release = Promise.withResolvers<void>()
103
+ const created = { count: 0 }
104
+ await page.route("**/api/pty*", (route) => {
105
+ created.count += 1
106
+ return route.fulfill({
107
+ status: 200,
108
+ contentType: "application/json",
109
+ body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
110
+ })
111
+ })
112
+ await page.route(/ghostty-web/, async (route) => {
113
+ ghostty.resolve()
114
+ await release.promise
115
+ await route.continue()
116
+ })
117
+ await seedCachedTerminal(page)
118
+
119
+ await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" })
120
+ await expectSessionTitle(page, "Terminal composer focus")
121
+
122
+ const composer = page.locator('[data-component="prompt-input"]')
123
+ const terminal = page.locator('[data-component="terminal"]')
124
+ await expect(terminal).toBeVisible()
125
+ expect(created.count).toBe(0)
126
+ await ghostty.promise
127
+ await composer.click()
128
+ await expect(composer).toBeFocused()
129
+
130
+ release.resolve()
131
+ await expect(terminal.locator("textarea")).toHaveCount(1)
132
+ await page.waitForTimeout(300)
133
+ await expect(composer).toBeFocused()
134
+ })
135
+
136
+ test("keeps newer composer focus while an explicit terminal open finishes", async ({ page }) => {
137
+ const ghostty = Promise.withResolvers<void>()
138
+ const release = Promise.withResolvers<void>()
139
+ await page.route(/ghostty-web/, async (route) => {
140
+ ghostty.resolve()
141
+ await release.promise
142
+ await route.continue()
143
+ })
144
+
145
+ await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
146
+ await expectSessionTitle(page, "Terminal composer focus")
147
+
148
+ const composer = page.locator('[data-component="prompt-input"]')
149
+ const terminal = page.locator('[data-component="terminal"]')
150
+ await page.keyboard.press("Control+Backquote")
151
+ await expect(terminal).toBeVisible()
152
+ await ghostty.promise
153
+ await composer.click()
154
+ await expect(composer).toBeFocused()
155
+
156
+ release.resolve()
157
+ await expect(terminal.locator("textarea")).toHaveCount(1)
158
+ await page.waitForTimeout(50)
159
+ await expect(composer).toBeFocused()
160
+ })
161
+
162
+ test("focuses a terminal created from the new-terminal button", async ({ page }) => {
163
+ const created = { count: 0 }
164
+ await page.route("**/api/pty*", (route) => {
165
+ created.count += 1
166
+ const next = created.count === 1 ? ptyInfo(ptyID, "Terminal 1") : ptyInfo(newPtyID, "Terminal 2")
167
+ return route.fulfill({
168
+ status: 200,
169
+ contentType: "application/json",
170
+ body: JSON.stringify({ location: ptyLocation(), data: next }),
171
+ })
172
+ })
173
+ await page.route(`**/api/pty/${newPtyID}*`, (route) =>
174
+ route.fulfill({
175
+ status: 200,
176
+ contentType: "application/json",
177
+ body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(newPtyID, "Terminal 2") }),
178
+ }),
179
+ )
180
+ await page.route(`**/api/pty/${newPtyID}/connect-token*`, (route) =>
181
+ route.fulfill({
182
+ status: 200,
183
+ contentType: "application/json",
184
+ headers: { "access-control-allow-origin": "*" },
185
+ body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
186
+ }),
187
+ )
188
+ await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined)
189
+
190
+ await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
191
+ await expectSessionTitle(page, "Terminal composer focus")
192
+
193
+ const composer = page.locator('[data-component="prompt-input"]')
194
+ const terminal = page.locator('[data-component="terminal"]')
195
+ await page.keyboard.press("Control+Backquote")
196
+ await expect(terminal.locator("textarea")).toHaveCount(1)
197
+ await composer.click()
198
+ await expect(composer).toBeFocused()
199
+
200
+ await page.getByRole("button", { name: "New terminal" }).click()
201
+ await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true")
202
+ await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
203
+ })
204
+
205
+ function seedCachedTerminal(page: Page) {
206
+ return page.addInitScript(
207
+ ({ terminalKey, ptyID }) => {
208
+ localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
209
+ localStorage.setItem(
210
+ terminalKey,
211
+ JSON.stringify({
212
+ active: ptyID,
213
+ all: [{ id: ptyID, title: "Terminal 1", titleNumber: 1 }],
214
+ }),
215
+ )
216
+ },
217
+ { terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
218
+ )
219
+ }
220
+
221
+ function ptyLocation() {
222
+ return { directory, project: { id: projectID, directory } }
223
+ }
224
+
225
+ function ptyInfo(id: string, title: string) {
226
+ return { id, title, command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
227
+ }
packages/app/e2e/regression/terminal-hidden.spec.ts ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from "@playwright/test"
2
+ import { mockOpenCodeServer } from "../utils/mock-server"
3
+ import { expectSessionTitle } from "../utils/waits"
4
+
5
+ const directory = "C:/OpenCode/HiddenTerminalRegression"
6
+ const projectID = "proj_hidden_terminal_regression"
7
+ const sessionID = "ses_hidden_terminal_regression"
8
+ const title = "Hidden terminal regression"
9
+
10
+ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
11
+ await page.setViewportSize({ width: 1400, height: 900 })
12
+ await mockOpenCodeServer(page, {
13
+ protocol: "v2",
14
+ directory,
15
+ project: {
16
+ id: projectID,
17
+ worktree: directory,
18
+ vcs: "git",
19
+ name: "hidden-terminal-regression",
20
+ time: { created: 1700000000000, updated: 1700000000000 },
21
+ sandboxes: [],
22
+ },
23
+ provider: {
24
+ all: [
25
+ {
26
+ id: "opencode",
27
+ name: "OpenCode",
28
+ models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
29
+ },
30
+ ],
31
+ connected: ["opencode"],
32
+ default: { providerID: "opencode", modelID: "test" },
33
+ },
34
+ sessions: [
35
+ {
36
+ id: sessionID,
37
+ slug: "hidden-terminal-regression",
38
+ projectID,
39
+ directory,
40
+ title,
41
+ version: "dev",
42
+ time: { created: 1700000000000, updated: 1700000000000 },
43
+ },
44
+ ],
45
+ pageMessages: () => ({ items: [] }),
46
+ })
47
+ await page.route("**/api/pty*", (route) =>
48
+ route.fulfill({
49
+ status: 200,
50
+ contentType: "application/json",
51
+ body: JSON.stringify({
52
+ location: { directory, project: { id: projectID, directory } },
53
+ data: {
54
+ id: "pty_hidden_terminal",
55
+ title: "Terminal 1",
56
+ command: "cmd.exe",
57
+ args: [],
58
+ cwd: directory,
59
+ status: "running",
60
+ pid: 1,
61
+ },
62
+ }),
63
+ }),
64
+ )
65
+ await page.route("**/api/pty/pty_hidden_terminal*", (route) =>
66
+ route.fulfill({
67
+ status: 200,
68
+ contentType: "application/json",
69
+ body: JSON.stringify({
70
+ location: { directory, project: { id: projectID, directory } },
71
+ data: {
72
+ id: "pty_hidden_terminal",
73
+ title: "Terminal 1",
74
+ command: "cmd.exe",
75
+ args: [],
76
+ cwd: directory,
77
+ status: "running",
78
+ pid: 1,
79
+ },
80
+ }),
81
+ }),
82
+ )
83
+ await page.route("**/api/pty/pty_hidden_terminal/connect-token*", (route) =>
84
+ route.fulfill({
85
+ status: 200,
86
+ contentType: "application/json",
87
+ body: JSON.stringify({
88
+ location: { directory, project: { id: projectID, directory } },
89
+ data: { ticket: "e2e-ticket", expires_in: 60 },
90
+ }),
91
+ }),
92
+ )
93
+ await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined)
94
+
95
+ await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
96
+ await expectSessionTitle(page, title)
97
+
98
+ await page.keyboard.press("Control+Backquote")
99
+ const panel = page.locator("#terminal-panel")
100
+ await expect(panel).toHaveAttribute("aria-hidden", "false")
101
+ await expect(page.locator('[data-component="terminal"]')).toBeVisible()
102
+
103
+ await page.keyboard.press("Control+Backquote")
104
+ await expect(panel).toHaveCount(0)
105
+ await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
106
+
107
+ await page.setViewportSize({ width: 1200, height: 700 })
108
+ await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
109
+
110
+ await page.keyboard.press("Control+Backquote")
111
+ await expect(panel).toBeVisible()
112
+ await expect(page.locator('[data-component="terminal"]')).toBeVisible()
113
+ })
114
+
115
+ function base64Encode(value: string) {
116
+ return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
117
+ }
packages/app/e2e/regression/terminal-tab-switch.spec.ts ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { base64Encode } from "@opencode-ai/core/util/encode"
2
+ import { expect, test, type Page } from "@playwright/test"
3
+ import { mockOpenCodeServer } from "../utils/mock-server"
4
+ import { expectSessionTitle } from "../utils/waits"
5
+
6
+ const directory = "C:/OpenCode/TerminalTabSwitch"
7
+ const projectID = "proj_terminal_tab_switch"
8
+ const sessionA = "ses_terminal_tab_a"
9
+ const sessionB = "ses_terminal_tab_b"
10
+ const titleA = "Alpha session"
11
+ const titleB = "Beta session"
12
+ const ptyID = "pty_tab_switch"
13
+ const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
14
+ // Marks the terminal DOM node so a remount (fresh node) is detectable.
15
+ const PROBE = "original"
16
+
17
+ test.use({ viewport: { width: 1440, height: 900 } })
18
+
19
+ // Terminals are workspace-scoped: switching between session tabs in the same
20
+ // workspace must keep the terminal mounted and its PTY connection open instead
21
+ // of tearing it down and reconnecting.
22
+ test("keeps the terminal session alive when switching session tabs in a workspace", async ({ page }) => {
23
+ const connections = await setup(page)
24
+
25
+ await page.goto(sessionHref(sessionA))
26
+ await expectSessionTitle(page, titleA)
27
+
28
+ await page.keyboard.press("Control+Backquote")
29
+ const terminal = page.locator('[data-component="terminal"]')
30
+ await expect(terminal).toBeVisible()
31
+ await expect.poll(() => connections.length).toBe(1)
32
+ const connection = new URL(connections[0]!)
33
+ expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
34
+ expect(connection.searchParams.get("location[directory]")).toBe(directory)
35
+ expect(connection.searchParams.get("ticket")).toBeNull()
36
+ await writeProbe(page)
37
+
38
+ await switchTab(page, titleB)
39
+ await expectSessionTitle(page, titleB)
40
+ await expect(terminal).toBeVisible()
41
+ expect(await readProbe(page)).toBe(PROBE)
42
+ expect(connections.length).toBe(1)
43
+
44
+ await switchTab(page, titleA)
45
+ await expectSessionTitle(page, titleA)
46
+ await expect(terminal).toBeVisible()
47
+ expect(await readProbe(page)).toBe(PROBE)
48
+ expect(connections.length).toBe(1)
49
+ })
50
+
51
+ type Probed = HTMLElement & { __e2eProbe?: string }
52
+
53
+ async function switchTab(page: Page, title: string) {
54
+ await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
55
+ }
56
+
57
+ async function writeProbe(page: Page) {
58
+ await page.locator('[data-component="terminal"]').evaluate((el, probe) => {
59
+ ;(el as Probed).__e2eProbe = probe
60
+ }, PROBE)
61
+ }
62
+
63
+ async function readProbe(page: Page) {
64
+ return page.locator('[data-component="terminal"]').evaluate((el) => (el as Probed).__e2eProbe)
65
+ }
66
+
67
+ async function setup(page: Page) {
68
+ await mockOpenCodeServer(page, {
69
+ protocol: "v2",
70
+ directory,
71
+ project: {
72
+ id: projectID,
73
+ worktree: directory,
74
+ vcs: "git",
75
+ name: "terminal-tab-switch",
76
+ time: { created: 1700000000000, updated: 1700000000000 },
77
+ sandboxes: [],
78
+ },
79
+ provider: {
80
+ all: [
81
+ {
82
+ id: "opencode",
83
+ name: "OpenCode",
84
+ models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
85
+ },
86
+ ],
87
+ connected: ["opencode"],
88
+ default: { providerID: "opencode", modelID: "test" },
89
+ },
90
+ sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
91
+ pageMessages: () => ({ items: [] }),
92
+ })
93
+ await page.route("**/api/pty*", (route) =>
94
+ route.fulfill({
95
+ status: 200,
96
+ contentType: "application/json",
97
+ body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
98
+ }),
99
+ )
100
+ await page.route(`**/api/pty/${ptyID}*`, (route) =>
101
+ route.fulfill({
102
+ status: 200,
103
+ contentType: "application/json",
104
+ body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
105
+ }),
106
+ )
107
+ await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => {
108
+ expect(route.request().headers()["x-opencode-ticket"]).toBe("1")
109
+ const url = new URL(route.request().url())
110
+ expect(url.searchParams.get("location[directory]")).toBe(directory)
111
+ return route.fulfill({
112
+ status: 200,
113
+ contentType: "application/json",
114
+ headers: { "access-control-allow-origin": "*" },
115
+ body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
116
+ })
117
+ })
118
+ const connections: string[] = []
119
+ await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => {
120
+ connections.push(ws.url())
121
+ })
122
+
123
+ await page.addInitScript(
124
+ ({ directory, server, sessions }) => {
125
+ localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
126
+ localStorage.setItem(
127
+ "opencode.global.dat:server",
128
+ JSON.stringify({
129
+ projects: { local: [{ worktree: directory, expanded: true }] },
130
+ lastProject: { local: directory },
131
+ }),
132
+ )
133
+ localStorage.setItem(
134
+ "opencode.window.browser.dat:tabs",
135
+ JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
136
+ )
137
+ },
138
+ { directory, server, sessions: [sessionA, sessionB] },
139
+ )
140
+ return connections
141
+ }
142
+
143
+ function session(id: string, title: string, created: number) {
144
+ return {
145
+ id,
146
+ slug: id,
147
+ projectID,
148
+ directory,
149
+ title,
150
+ version: "dev",
151
+ time: { created, updated: created },
152
+ }
153
+ }
154
+
155
+ function sessionHref(sessionID: string) {
156
+ return `/server/${base64Encode(server)}/session/${sessionID}`
157
+ }
158
+
159
+ function ptyLocation() {
160
+ return { directory, project: { id: projectID, directory } }
161
+ }
162
+
163
+ function ptyInfo() {
164
+ return { id: ptyID, title: "Terminal 1", command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
165
+ }
packages/app/e2e/smoke/session-timeline.fixture.ts ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const words = [
2
+ "alpha",
3
+ "bravo",
4
+ "charlie",
5
+ "delta",
6
+ "echo",
7
+ "foxtrot",
8
+ "golf",
9
+ "hotel",
10
+ "india",
11
+ "juliet",
12
+ "kilo",
13
+ "lima",
14
+ "metro",
15
+ "nova",
16
+ "orbit",
17
+ "pixel",
18
+ "quartz",
19
+ "river",
20
+ "signal",
21
+ "vector",
22
+ ]
23
+
24
+ const serverKey = "http://127.0.0.1:4096"
25
+ const sourceID = "ses_smoke_source"
26
+ const targetID = "ses_smoke_target"
27
+ const directory = "C:/OpenCode/SmokeProject"
28
+ const projectID = "proj_smoke_timeline"
29
+ const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
30
+
31
+ type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
32
+ type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
33
+ type Message = { info: MessageInfo; parts: MessagePart[] }
34
+
35
+ function lorem(seed: number, length: number) {
36
+ let out = ""
37
+ let i = seed
38
+ while (out.length < length) {
39
+ const word = words[i % words.length]
40
+ out += (out ? " " : "") + word
41
+ if (i % 17 === 0) out += ".\n\n"
42
+ i += 7
43
+ }
44
+ return out.slice(0, length)
45
+ }
46
+
47
+ function id(prefix: string, value: number) {
48
+ return `${prefix}_smoke_${String(value).padStart(4, "0")}`
49
+ }
50
+
51
+ function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
52
+ const messageID = id("msg_user", index)
53
+ return {
54
+ info: {
55
+ id: messageID,
56
+ sessionID,
57
+ role: "user",
58
+ time: { created: 1700000000000 + index * 10_000 },
59
+ summary: { diffs },
60
+ agent: "build",
61
+ model,
62
+ },
63
+ parts: [
64
+ {
65
+ id: id("prt_user_text", index),
66
+ sessionID,
67
+ messageID,
68
+ type: "text",
69
+ text: lorem(index, textLength),
70
+ },
71
+ ],
72
+ }
73
+ }
74
+
75
+ function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
76
+ const messageID = id("msg_assistant", index)
77
+ return {
78
+ info: {
79
+ id: messageID,
80
+ sessionID,
81
+ role: "assistant",
82
+ time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
83
+ parentID,
84
+ modelID: model.modelID,
85
+ providerID: model.providerID,
86
+ mode: "build",
87
+ agent: "build",
88
+ path: { cwd: directory, root: directory },
89
+ cost: 0.01,
90
+ tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
91
+ variant: "max",
92
+ finish: "stop",
93
+ },
94
+ parts: parts.map((part) => ({
95
+ ...part,
96
+ sessionID,
97
+ messageID,
98
+ })),
99
+ }
100
+ }
101
+
102
+ function textPart(index: number, partIndex: number, length: number): MessagePart {
103
+ return { id: id(`prt_text_${partIndex}`, index), type: "text", text: lorem(index * 13 + partIndex, length) }
104
+ }
105
+
106
+ function reasoningPart(index: number, partIndex: number, length: number): MessagePart {
107
+ return {
108
+ id: id(`prt_reasoning_${partIndex}`, index),
109
+ type: "reasoning",
110
+ text: lorem(index * 19 + partIndex, length),
111
+ time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 500 },
112
+ }
113
+ }
114
+
115
+ function toolPart(
116
+ index: number,
117
+ partIndex: number,
118
+ tool: string,
119
+ input: Record<string, unknown>,
120
+ outputLength = 160,
121
+ ): MessagePart {
122
+ const metadata =
123
+ tool === "apply_patch"
124
+ ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
125
+ : tool === "edit" || tool === "write"
126
+ ? {
127
+ filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
128
+ diff: patch(index, outputLength),
129
+ preview: patch(index + 1, 420),
130
+ }
131
+ : tool === "question"
132
+ ? { answers: [["Proceed"], ["Keep sample output"]] }
133
+ : {}
134
+ return {
135
+ id: id(`prt_tool_${tool}_${partIndex}`, index),
136
+ type: "tool",
137
+ callID: id("call", index * 10 + partIndex),
138
+ tool,
139
+ state: {
140
+ status: "completed",
141
+ input,
142
+ output: lorem(index * 23 + partIndex, outputLength),
143
+ title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed",
144
+ metadata,
145
+ time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
146
+ },
147
+ }
148
+ }
149
+
150
+ function patchFile(seed: number, type: "add" | "update" | "delete") {
151
+ return {
152
+ filePath: `src/generated/patch-${seed}.ts`,
153
+ relativePath: `src/generated/patch-${seed}.ts`,
154
+ type,
155
+ additions: (seed % 7) + 1,
156
+ deletions: type === "add" ? 0 : seed % 4,
157
+ patch: patch(seed, 520),
158
+ before: type === "add" ? undefined : code(seed, 18),
159
+ after: type === "delete" ? undefined : code(seed + 1, 24),
160
+ }
161
+ }
162
+
163
+ function fileDiff(file: string, seed: number) {
164
+ return {
165
+ file,
166
+ additions: (seed % 9) + 1,
167
+ deletions: seed % 4,
168
+ before: code(seed, 32),
169
+ after: code(seed + 1, 38),
170
+ }
171
+ }
172
+
173
+ function patch(seed: number, length: number) {
174
+ return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
175
+ }
176
+
177
+ function code(seed: number, lines: number) {
178
+ return Array.from({ length: lines }, (_, index) => `export const value${index} = "${lorem(seed + index, 32)}"`).join(
179
+ "\n",
180
+ )
181
+ }
182
+
183
+ function turn(index: number): Message[] {
184
+ const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
185
+ const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
186
+ const parts = [
187
+ ...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
188
+ ...(index % 3 === 0
189
+ ? [
190
+ toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
191
+ toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
192
+ toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
193
+ toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
194
+ ]
195
+ : []),
196
+ textPart(index, 2, 160 + (index % 6) * 90),
197
+ ...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
198
+ ...(index % 6 === 0
199
+ ? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
200
+ : []),
201
+ ...(index % 8 === 0
202
+ ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
203
+ : []),
204
+ ...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
205
+ ...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
206
+ ...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
207
+ ...(index % 13 === 0
208
+ ? [
209
+ toolPart(
210
+ index,
211
+ 11,
212
+ "question",
213
+ { questions: [{ question: "Use generated fixture?" }, { question: "Keep same row shape?" }] },
214
+ 120,
215
+ ),
216
+ ]
217
+ : []),
218
+ ...(index % 17 === 0
219
+ ? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
220
+ : []),
221
+ ]
222
+ return [user, assistantMessage(targetID, index, user.info.id, parts)]
223
+ }
224
+
225
+ const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
226
+ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
227
+ userMessage(sourceID, index + 1000, 120),
228
+ assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
229
+ ]).flat()
230
+
231
+ function renderable(part: MessagePart) {
232
+ if (part.type === "tool" && part.tool === "todowrite") return false
233
+ if (part.type === "text") return !!part.text.trim()
234
+ if (part.type === "reasoning") return !!part.text.trim()
235
+ return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
236
+ }
237
+
238
+ function orderedParts(message: Message) {
239
+ return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
240
+ }
241
+
242
+ export const fixture = {
243
+ directory,
244
+ serverKey,
245
+ project: {
246
+ id: projectID,
247
+ worktree: directory,
248
+ vcs: "git",
249
+ name: "smoke-project",
250
+ time: { created: 1700000000000, updated: 1700000000000 },
251
+ sandboxes: [],
252
+ },
253
+ provider: {
254
+ all: [
255
+ {
256
+ id: "opencode",
257
+ name: "OpenCode",
258
+ models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
259
+ },
260
+ ],
261
+ connected: ["opencode"],
262
+ default: { providerID: "opencode", modelID: "claude-opus-4-6" },
263
+ },
264
+ sessions: [
265
+ {
266
+ id: sourceID,
267
+ slug: "source",
268
+ projectID,
269
+ directory,
270
+ title: "Uncommitted changes inquiry",
271
+ version: "dev",
272
+ time: { created: 1700000000000, updated: 1700000000000 },
273
+ },
274
+ {
275
+ id: targetID,
276
+ slug: "target",
277
+ projectID,
278
+ directory,
279
+ title: "Example Game: sample jump movement & sample physics analysis",
280
+ version: "dev",
281
+ time: { created: 1700000001000, updated: 1700000001000 },
282
+ },
283
+ ],
284
+ sourceID,
285
+ targetID,
286
+ messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
287
+ expected: {
288
+ sourceTitle: "Uncommitted changes inquiry",
289
+ targetTitle: "Example Game: sample jump movement & sample physics analysis",
290
+ targetMessageIDs: targetMessages
291
+ .filter((message) => message.info.role === "user")
292
+ .map((message) => message.info.id),
293
+ targetPartIDs: targetMessages.flatMap((message) =>
294
+ orderedParts(message)
295
+ .filter(renderable)
296
+ .map((part) => part.id),
297
+ ),
298
+ expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id,
299
+ },
300
+ }
301
+
302
+ export function pageMessages(sessionID: string, limit: number, before?: string) {
303
+ const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
304
+ const end = before
305
+ ? Math.max(
306
+ 0,
307
+ messages.findIndex((message) => message.info.id === before),
308
+ )
309
+ : messages.length
310
+ const start = Math.max(0, end - limit)
311
+ return {
312
+ items: messages.slice(start, end),
313
+ cursor: start > 0 ? messages[start]!.info.id : undefined,
314
+ }
315
+ }
packages/app/e2e/smoke/session-timeline.spec.ts ADDED
@@ -0,0 +1,740 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test, type Page } from "@playwright/test"
2
+ import { base64Encode } from "@opencode-ai/core/util/encode"
3
+ import { fixture, pageMessages } from "./session-timeline.fixture"
4
+ import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
5
+ import { mockOpenCodeServer } from "../utils/mock-server"
6
+ import { APP_READY_TIMEOUT, expectAppVisible, expectSessionTitle } from "../utils/waits"
7
+
8
+ const forbiddenText = ["Load details", "Show earlier steps"]
9
+
10
+ type SmokeState = {
11
+ ids: string[]
12
+ visibleIds: string[]
13
+ messageIds: string[]
14
+ visibleMessageIds: string[]
15
+ topVisibleId?: string
16
+ signature: string
17
+ scrollTop: number
18
+ scrollHeight: number
19
+ clientHeight: number
20
+ errorToasts: string[]
21
+ forbiddenText: string[]
22
+ }
23
+
24
+ type SmokeWindow = Window & {
25
+ __timelineSmokeState?: () => SmokeState
26
+ __timelineSmokeErrorToasts?: string[]
27
+ __timelineSmokeForbiddenText?: string[]
28
+ }
29
+
30
+ test.describe("smoke: session timeline", () => {
31
+ test.setTimeout(240_000)
32
+
33
+ test("keeps the visible message fixed while prepending history", async ({ page }) => {
34
+ const requests: { before?: string; phase: "start" | "end"; at: number }[] = []
35
+ await mockOpenCodeServer(page, {
36
+ sessions: fixture.sessions,
37
+ provider: fixture.provider,
38
+ directory: fixture.directory,
39
+ project: fixture.project,
40
+ pageMessages,
41
+ messageDelay: 3_000,
42
+ onMessages: (input) => requests.push({ before: input.before, phase: input.phase, at: performance.now() }),
43
+ })
44
+ await configureSmokePage(page, fixture.directory)
45
+
46
+ await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
47
+ await waitForTimelineStable(page)
48
+ const scroller = timelineScroller(page)
49
+ await pointAtTimeline(page)
50
+ const deadline = Date.now() + 120_000
51
+ while (!requests.some((request) => request.before && request.phase === "start")) {
52
+ if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary")
53
+ await page.mouse.wheel(0, -240)
54
+ await page.waitForTimeout(20)
55
+ }
56
+ expect(requests.some((request) => request.before && request.phase === "end")).toBe(false)
57
+ for (let index = 0; index < 12; index++) {
58
+ await page.mouse.wheel(0, -120)
59
+ await page.waitForTimeout(20)
60
+ }
61
+ const keys = await scroller.evaluate((element) => {
62
+ const view = element.getBoundingClientRect()
63
+ return [...element.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
64
+ .filter((row) => {
65
+ const rect = row.getBoundingClientRect()
66
+ return rect.bottom > view.top && rect.top < view.bottom
67
+ })
68
+ .map((row) => row.dataset.timelinePartId)
69
+ .filter((id): id is string => !!id)
70
+ .slice(0, 3)
71
+ })
72
+ expect(keys.length).toBeGreaterThan(0)
73
+ const positions = () =>
74
+ scroller.evaluate((element, keys) => {
75
+ const top = element.getBoundingClientRect().top
76
+ return Object.fromEntries(
77
+ keys.map((key) => {
78
+ const row = element.querySelector<HTMLElement>(`[data-timeline-part-id="${key}"]`)
79
+ if (!row) throw new Error(`Missing stable timeline key: ${key}`)
80
+ return [key, Math.round((row.getBoundingClientRect().top - top) * devicePixelRatio) / devicePixelRatio]
81
+ }),
82
+ )
83
+ }, keys)
84
+ const before = await positions()
85
+ expect(requests.some((request) => request.before && request.phase === "end")).toBe(false)
86
+
87
+ await expect.poll(() => requests.some((request) => request.before && request.phase === "end")).toBe(true)
88
+ await waitForTimelineStable(page)
89
+ await expect.poll(positions).toEqual(before)
90
+ })
91
+
92
+ test("preserves the timeline gap above the composer", async ({ page }) => {
93
+ await mockOpenCodeServer(page, {
94
+ sessions: fixture.sessions,
95
+ provider: fixture.provider,
96
+ directory: fixture.directory,
97
+ project: fixture.project,
98
+ pageMessages,
99
+ })
100
+ await configureSmokePage(page, fixture.directory)
101
+
102
+ await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
103
+ await waitForTimelineStable(page)
104
+ const scroller = timelineScroller(page)
105
+ await scroller.evaluate((element) => {
106
+ element.scrollTop = element.scrollHeight
107
+ })
108
+ await waitForTimelineStable(page)
109
+
110
+ const spacer = scroller.locator('[data-timeline-row="bottom-spacer"]')
111
+ await expect(spacer).toBeVisible()
112
+ expect(await spacer.evaluate((element) => element.getBoundingClientRect().height)).toBe(64)
113
+ await expect
114
+ .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
115
+ .toBeLessThanOrEqual(1)
116
+ })
117
+
118
+ test("paints cached session tabs at the latest message", async ({ page }) => {
119
+ await mockOpenCodeServer(page, {
120
+ sessions: fixture.sessions,
121
+ provider: fixture.provider,
122
+ directory: fixture.directory,
123
+ project: fixture.project,
124
+ pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
125
+ })
126
+ await configureSmokePage(page, fixture.directory)
127
+ await page.addInitScript(
128
+ ({ dirBase64, sourceID, targetID }) => {
129
+ localStorage.setItem(
130
+ "opencode.window.browser.dat:tabs",
131
+ JSON.stringify(
132
+ [sourceID, targetID].map((sessionId) => ({
133
+ type: "session",
134
+ server: "http://127.0.0.1:4096",
135
+ dirBase64,
136
+ sessionId,
137
+ })),
138
+ ),
139
+ )
140
+ },
141
+ { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
142
+ )
143
+
144
+ await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`)
145
+ await expectSessionTitle(page, fixture.expected.targetTitle)
146
+ await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle)
147
+
148
+ const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
149
+ const last = fixture.expected.targetMessageIDs.at(-1)!
150
+ await page.evaluate(
151
+ ({ destination, last }) => {
152
+ const ids = new Set(destination)
153
+ const samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> = []
154
+ const firstPaintNodes = new WeakSet<Node>()
155
+ let firstPaint = false
156
+ let removedFirstPaintNodes = 0
157
+ let running = true
158
+ new MutationObserver((records) => {
159
+ if (!firstPaint || !running) return
160
+ records.forEach((record) =>
161
+ record.removedNodes.forEach((node) => {
162
+ if (firstPaintNodes.has(node)) removedFirstPaintNodes += 1
163
+ if (!(node instanceof Element)) return
164
+ node.querySelectorAll("*").forEach((element) => {
165
+ if (firstPaintNodes.has(element)) removedFirstPaintNodes += 1
166
+ })
167
+ }),
168
+ )
169
+ }).observe(document.documentElement, { childList: true, subtree: true })
170
+ const sample = () => {
171
+ if (!running) return
172
+ setTimeout(() => {
173
+ if (!running) return
174
+ const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
175
+ element.querySelector("[data-timeline-row]"),
176
+ )
177
+ if (root) {
178
+ const view = root.getBoundingClientRect()
179
+ const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
180
+ .filter((element) => {
181
+ const rect = element.getBoundingClientRect()
182
+ return rect.bottom > view.top && rect.top < view.bottom
183
+ })
184
+ .map((element) => element.dataset.messageId!)
185
+ .filter((id) => ids.has(id))
186
+ const bottom = root
187
+ .querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
188
+ ?.getBoundingClientRect()
189
+ samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
190
+ if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) {
191
+ firstPaint = true
192
+ root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => {
193
+ const rect = row.getBoundingClientRect()
194
+ if (rect.bottom <= view.top || rect.top >= view.bottom) return
195
+ firstPaintNodes.add(row)
196
+ row.querySelectorAll("*").forEach((element) => firstPaintNodes.add(element))
197
+ })
198
+ }
199
+ }
200
+ requestAnimationFrame(sample)
201
+ }, 0)
202
+ }
203
+ ;(
204
+ window as Window & {
205
+ __sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void }
206
+ }
207
+ ).__sessionTabPaint = {
208
+ samples,
209
+ removed: () => removedFirstPaintNodes,
210
+ stop: () => {
211
+ running = false
212
+ },
213
+ }
214
+ requestAnimationFrame(sample)
215
+ },
216
+ { destination, last },
217
+ )
218
+
219
+ await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
220
+ await page.waitForFunction(() =>
221
+ (
222
+ window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } }
223
+ ).__sessionTabPaint?.samples.some((sample) => sample.ids.length > 0),
224
+ )
225
+ await page.waitForTimeout(200)
226
+ const first = await page.evaluate(() => {
227
+ const probe = (
228
+ window as Window & {
229
+ __sessionTabPaint?: {
230
+ samples: Array<{ ids: string[]; last: boolean; bottomError?: number }>
231
+ removed: () => number
232
+ stop: () => void
233
+ }
234
+ }
235
+ ).__sessionTabPaint!
236
+ probe.stop()
237
+ return { first: probe.samples.find((sample) => sample.ids.length > 0), removed: probe.removed() }
238
+ })
239
+ expect(first.first?.last).toBe(true)
240
+ expect(Math.abs(first.first?.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
241
+ expect(first.removed).toBe(0)
242
+ })
243
+
244
+ test("paints a cold session tab at the latest message", async ({ page }) => {
245
+ await mockOpenCodeServer(page, {
246
+ sessions: fixture.sessions,
247
+ provider: fixture.provider,
248
+ directory: fixture.directory,
249
+ project: fixture.project,
250
+ pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
251
+ })
252
+ await configureSmokePage(page, fixture.directory)
253
+ await page.addInitScript(
254
+ ({ dirBase64, sourceID, targetID }) => {
255
+ localStorage.setItem(
256
+ "opencode.window.browser.dat:tabs",
257
+ JSON.stringify(
258
+ [sourceID, targetID].map((sessionId) => ({
259
+ type: "session",
260
+ server: "http://127.0.0.1:4096",
261
+ dirBase64,
262
+ sessionId,
263
+ })),
264
+ ),
265
+ )
266
+ },
267
+ { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
268
+ )
269
+ await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`)
270
+ await expectSessionTitle(page, fixture.expected.sourceTitle)
271
+ const last = fixture.expected.targetMessageIDs.at(-1)!
272
+ const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
273
+ await page.evaluate(
274
+ ({ destination, last }) => {
275
+ const ids = new Set(destination)
276
+ const samples: Array<{ destination: boolean; last: boolean; bottomError?: number }> = []
277
+ const sample = () => {
278
+ const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
279
+ element.querySelector("[data-timeline-row]"),
280
+ )
281
+ if (root) {
282
+ const view = root.getBoundingClientRect()
283
+ const spacer = root
284
+ .querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
285
+ ?.getBoundingClientRect()
286
+ const messages = [...root.querySelectorAll<HTMLElement>("[data-message-id]")].filter((element) => {
287
+ const rect = element.getBoundingClientRect()
288
+ return rect.bottom > view.top && rect.top < view.bottom
289
+ })
290
+ samples.push({
291
+ destination: messages.some((element) => ids.has(element.dataset.messageId!)),
292
+ last: messages.some((element) => element.dataset.messageId === last),
293
+ bottomError: spacer ? spacer.bottom - view.bottom : undefined,
294
+ })
295
+ }
296
+ requestAnimationFrame(() => setTimeout(sample, 0))
297
+ }
298
+ ;(window as Window & { __coldTabSamples?: typeof samples }).__coldTabSamples = samples
299
+ requestAnimationFrame(() => setTimeout(sample, 0))
300
+ },
301
+ { destination, last },
302
+ )
303
+
304
+ await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
305
+ await page.waitForFunction(() =>
306
+ (window as Window & { __coldTabSamples?: Array<{ destination: boolean }> }).__coldTabSamples?.some(
307
+ (sample) => sample.destination,
308
+ ),
309
+ )
310
+ const result = await page.evaluate(() => {
311
+ const samples = (
312
+ window as Window & {
313
+ __coldTabSamples?: Array<{ destination: boolean; last: boolean; bottomError?: number }>
314
+ }
315
+ ).__coldTabSamples!
316
+ return samples.find((sample) => sample.destination)!
317
+ })
318
+ expect(result.last).toBe(true)
319
+ expect(Math.abs(result.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
320
+ })
321
+
322
+ test("renders seeded timeline in order while paging through history", async ({ page }) => {
323
+ const errors = trackPageErrors(page)
324
+ await mockOpenCodeServer(page, {
325
+ sessions: fixture.sessions,
326
+ provider: fixture.provider,
327
+ directory: fixture.directory,
328
+ project: fixture.project,
329
+ pageMessages,
330
+ })
331
+ await configureSmokePage(page, fixture.directory)
332
+
333
+ await selectHomeProject(page, fixture.project.name)
334
+ await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle)
335
+ await expectSessionReady(page)
336
+ await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
337
+ const expectedPartIDs = fixture.expected.targetPartIDs
338
+ const expectedMessageIDs = fixture.expected.targetMessageIDs
339
+ await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
340
+ await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
341
+
342
+ const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`)
343
+ const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
344
+ const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
345
+ await expect(shellSubtitle).toHaveCount(0)
346
+ await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck")
347
+ await shellTrigger.click()
348
+ await expect(shellTrigger).toHaveAttribute("aria-expanded", "false")
349
+ await expect(shellSubtitle).toHaveText("bun typecheck")
350
+ await shellTrigger.click()
351
+ await expect(shellTrigger).toHaveAttribute("aria-expanded", "true")
352
+ await expect(shellSubtitle).toHaveCount(0)
353
+ })
354
+ })
355
+
356
+ async function configureSmokePage(page: Page, directory: string) {
357
+ await page.addInitScript(() => {
358
+ localStorage.setItem(
359
+ "settings.v3",
360
+ JSON.stringify({
361
+ general: {
362
+ editToolPartsExpanded: true,
363
+ shellToolPartsExpanded: true,
364
+ showReasoningSummaries: true,
365
+ },
366
+ }),
367
+ )
368
+ })
369
+
370
+ await page.addInitScript((directory) => {
371
+ localStorage.setItem(
372
+ "opencode.global.dat:server",
373
+ JSON.stringify({
374
+ projects: {
375
+ local: [{ worktree: directory, expanded: true }],
376
+ },
377
+ lastProject: {
378
+ local: directory,
379
+ },
380
+ }),
381
+ )
382
+ }, directory)
383
+
384
+ await page.addInitScript(() => {
385
+ const smoke = window as SmokeWindow
386
+ smoke.__timelineSmokeErrorToasts = []
387
+ smoke.__timelineSmokeForbiddenText = []
388
+ const partSelector = "[data-timeline-part-id], [data-timeline-part-ids]"
389
+ const idsOf = (el: HTMLElement) =>
390
+ [el.dataset.timelinePartId, ...(el.dataset.timelinePartIds?.split(",") ?? [])].filter((id): id is string => !!id)
391
+
392
+ smoke.__timelineSmokeState = () => {
393
+ const scroller = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((el) =>
394
+ el.querySelector("[data-timeline-row], [data-session-title]"),
395
+ )
396
+ if (!scroller) {
397
+ return {
398
+ ids: [],
399
+ visibleIds: [],
400
+ messageIds: [],
401
+ visibleMessageIds: [],
402
+ topVisibleId: undefined,
403
+ signature: "",
404
+ scrollTop: 0,
405
+ scrollHeight: 0,
406
+ clientHeight: 0,
407
+ errorToasts: smoke.__timelineSmokeErrorToasts ?? [],
408
+ forbiddenText: smoke.__timelineSmokeForbiddenText ?? [],
409
+ }
410
+ }
411
+
412
+ const ids: string[] = []
413
+ const visibleIds: string[] = []
414
+ const scrollerRect = scroller.getBoundingClientRect()
415
+ let topVisibleId: string | undefined
416
+ for (const el of scroller.querySelectorAll<HTMLElement>(partSelector)) {
417
+ const next = idsOf(el)
418
+ ids.push(...next)
419
+
420
+ const rect = el.getBoundingClientRect()
421
+ if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) {
422
+ if (!topVisibleId) topVisibleId = next[0]
423
+ visibleIds.push(...next)
424
+ }
425
+ }
426
+
427
+ const messageIds: string[] = []
428
+ const visibleMessageIds: string[] = []
429
+ const rows = [...scroller.querySelectorAll<HTMLElement>("[data-message-id]")].map((el) => {
430
+ const rect = el.getBoundingClientRect()
431
+ const id = el.dataset.messageId
432
+ if (id) {
433
+ messageIds.push(id)
434
+ if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) visibleMessageIds.push(id)
435
+ }
436
+ return {
437
+ id,
438
+ top: Math.round(rect.top),
439
+ bottom: Math.round(rect.bottom),
440
+ }
441
+ })
442
+ const signature = JSON.stringify({
443
+ top: Math.round(scroller.scrollTop),
444
+ height: Math.round(scroller.scrollHeight),
445
+ rows,
446
+ ids,
447
+ })
448
+
449
+ return {
450
+ ids,
451
+ visibleIds,
452
+ messageIds,
453
+ visibleMessageIds,
454
+ topVisibleId,
455
+ signature,
456
+ scrollTop: Math.round(scroller.scrollTop),
457
+ scrollHeight: Math.round(scroller.scrollHeight),
458
+ clientHeight: Math.round(scroller.clientHeight),
459
+ errorToasts: smoke.__timelineSmokeErrorToasts ?? [],
460
+ forbiddenText: smoke.__timelineSmokeForbiddenText ?? [],
461
+ }
462
+ }
463
+ let recordFrame: number | undefined
464
+ const record = () => {
465
+ for (const toast of document.querySelectorAll<HTMLElement>('[data-component="toast"][data-variant="error"]')) {
466
+ const text = toast.textContent?.trim()
467
+ if (text && !smoke.__timelineSmokeErrorToasts!.includes(text)) smoke.__timelineSmokeErrorToasts!.push(text)
468
+ }
469
+ const text = document.body?.textContent ?? ""
470
+ for (const value of ["Load details", "Show earlier steps"]) {
471
+ if (text.includes(value) && !smoke.__timelineSmokeForbiddenText!.includes(value)) {
472
+ smoke.__timelineSmokeForbiddenText!.push(value)
473
+ }
474
+ }
475
+ }
476
+ const start = () => {
477
+ const root = document.documentElement ?? document.body
478
+ if (!root) return
479
+ new MutationObserver(() => {
480
+ if (recordFrame) return
481
+ recordFrame = requestAnimationFrame(() => {
482
+ recordFrame = undefined
483
+ record()
484
+ })
485
+ }).observe(root, { childList: true, subtree: true })
486
+ record()
487
+ }
488
+ if (document.documentElement ?? document.body) start()
489
+ else document.addEventListener("DOMContentLoaded", start, { once: true })
490
+ })
491
+ }
492
+
493
+ async function expectCanScrollToStart(
494
+ page: Page,
495
+ expectedPartIDs: string[],
496
+ expectedMessageIDs: string[],
497
+ errors: string[],
498
+ ) {
499
+ await pointAtTimeline(page)
500
+ const seenParts = new Set<string>()
501
+ const seenMessages = new Set<string>()
502
+ const samples: TraversalSample[] = []
503
+ let current = await timelineState(page)
504
+ let unchangedAtTop = 0
505
+
506
+ for (let attempt = 0; attempt < 600; attempt++) {
507
+ collectSeen(current, seenParts, seenMessages)
508
+ samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
509
+ expectNoSmokeErrors(errors, current.errorToasts, current.forbiddenText)
510
+ expectOrderedIDs(expectedPartIDs, current.ids, "mounted part")
511
+ expectOrderedIDs(expectedPartIDs, current.visibleIds, "visible part")
512
+ expectOrderedIDs(expectedMessageIDs, unique(current.messageIds), "mounted message")
513
+ expectOrderedIDs(expectedMessageIDs, unique(current.visibleMessageIds), "visible message")
514
+
515
+ if (
516
+ current.scrollTop <= 1 &&
517
+ seenParts.size === expectedPartIDs.length &&
518
+ seenMessages.size === expectedMessageIDs.length
519
+ ) {
520
+ expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples)
521
+ return
522
+ }
523
+
524
+ const before = current
525
+ const changed = await scrollTimelineUp(page, current)
526
+ current = await timelineState(page)
527
+ if (!changed && current.signature === before.signature && current.scrollTop <= 1) unchangedAtTop++
528
+ else unchangedAtTop = 0
529
+ if (unchangedAtTop >= 2) break
530
+ }
531
+
532
+ collectSeen(current, seenParts, seenMessages)
533
+ samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
534
+ expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples)
535
+ }
536
+
537
+ async function timelineState(page: Page) {
538
+ return page.evaluate(
539
+ () =>
540
+ (window as SmokeWindow).__timelineSmokeState?.() ?? {
541
+ ids: [],
542
+ visibleIds: [],
543
+ messageIds: [],
544
+ visibleMessageIds: [],
545
+ topVisibleId: undefined,
546
+ signature: "",
547
+ scrollTop: 0,
548
+ scrollHeight: 0,
549
+ clientHeight: 0,
550
+ errorToasts: [],
551
+ forbiddenText: [],
552
+ },
553
+ )
554
+ }
555
+
556
+ function timelineScroller(page: Page) {
557
+ return page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
558
+ }
559
+
560
+ async function pointAtTimeline(page: Page) {
561
+ const box = await timelineScroller(page).boundingBox()
562
+ if (!box) throw new Error("Timeline scroller is not visible")
563
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
564
+ }
565
+
566
+ async function scrollTimelineUp(page: Page, before: SmokeState) {
567
+ return page.evaluate(
568
+ (prev) =>
569
+ new Promise<boolean>((resolve) => {
570
+ const scroller = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((el) =>
571
+ el.querySelector("[data-timeline-row], [data-session-title]"),
572
+ )
573
+ if (!scroller) {
574
+ resolve(false)
575
+ return
576
+ }
577
+
578
+ scroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1, deltaMode: 0 }))
579
+ scroller.scrollTop = Math.max(0, scroller.scrollTop - Math.max(80, Math.round(scroller.clientHeight * 0.45)))
580
+
581
+ const read = () => (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
582
+ let frames = 0
583
+ let stableFrames = 0
584
+ let last = ""
585
+ let changed = false
586
+ const check = () => {
587
+ const current = read()
588
+ if (current !== prev) changed = true
589
+ if (current === last) stableFrames++
590
+ else {
591
+ stableFrames = 0
592
+ last = current
593
+ }
594
+ if (changed && stableFrames >= 2) {
595
+ resolve(true)
596
+ return
597
+ }
598
+ frames++
599
+ if (frames >= 30) {
600
+ resolve(changed)
601
+ return
602
+ }
603
+ requestAnimationFrame(check)
604
+ }
605
+ requestAnimationFrame(check)
606
+ }),
607
+ before.signature,
608
+ )
609
+ }
610
+
611
+ function expectOrderedIDs(expected: string[], actual: string[], label: string) {
612
+ expect(actual.length, `${label} ids should not be empty`).toBeGreaterThan(0)
613
+ const actualSet = new Set(actual)
614
+ expect(actual, `${label} ids`).toEqual(expected.filter((id) => actualSet.has(id)))
615
+ }
616
+
617
+ function unique(values: string[]) {
618
+ return values.filter((value, index) => values.indexOf(value) === index)
619
+ }
620
+
621
+ function collectSeen(state: SmokeState, seenParts: Set<string>, seenMessages: Set<string>) {
622
+ for (const id of state.ids) seenParts.add(id)
623
+ for (const id of state.visibleIds) seenParts.add(id)
624
+ for (const id of state.messageIds) seenMessages.add(id)
625
+ for (const id of state.visibleMessageIds) seenMessages.add(id)
626
+ }
627
+
628
+ type TraversalSample = ReturnType<typeof sampleTraversal>
629
+
630
+ function sampleTraversal(state: SmokeState, seenParts: number, seenMessages: number) {
631
+ return {
632
+ seenParts,
633
+ seenMessages,
634
+ mounted: state.ids.length,
635
+ visible: state.visibleIds.length,
636
+ mountedMessages: unique(state.messageIds).length,
637
+ visibleMessages: unique(state.visibleMessageIds).length,
638
+ top: state.scrollTop,
639
+ height: state.scrollHeight,
640
+ first: state.ids[0],
641
+ last: state.ids.at(-1),
642
+ topVisible: state.topVisibleId,
643
+ visibleFirst: state.visibleIds[0],
644
+ visibleLast: state.visibleIds.at(-1),
645
+ }
646
+ }
647
+
648
+ function sampleSummary(samples: TraversalSample[]) {
649
+ return samples
650
+ .filter((_, index) => index % Math.max(1, Math.floor(samples.length / 8)) === 0 || index === samples.length - 1)
651
+ .map(
652
+ (sample, index) =>
653
+ `${index}: seenParts=${sample.seenParts} seenMessages=${sample.seenMessages} mounted=${sample.mounted}/${sample.mountedMessages} visible=${sample.visible}/${sample.visibleMessages} top=${sample.top}/${sample.height} first=${sample.first} last=${sample.last} topVisible=${sample.topVisible} visible=${sample.visibleFirst}..${sample.visibleLast}`,
654
+ )
655
+ .join("\n")
656
+ }
657
+
658
+ async function waitForTimelineStable(page: Page) {
659
+ await page.waitForFunction(
660
+ () =>
661
+ new Promise<boolean>((resolve) => {
662
+ requestAnimationFrame(() => {
663
+ const a = (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
664
+ requestAnimationFrame(() => {
665
+ const b = (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
666
+ requestAnimationFrame(() =>
667
+ resolve(!!a && a === b && b === ((window as SmokeWindow).__timelineSmokeState?.().signature ?? "")),
668
+ )
669
+ })
670
+ })
671
+ }),
672
+ )
673
+ }
674
+
675
+ async function expectSessionTimelineReady(
676
+ page: Page,
677
+ expectedPartIDs: string[],
678
+ expectedMessageIDs: string[],
679
+ errors: string[],
680
+ ) {
681
+ await waitForTimelineStable(page)
682
+ for (const text of forbiddenText) await expect(page.getByText(text)).toHaveCount(0)
683
+ const currentState = await timelineState(page)
684
+ expectNoSmokeErrors(errors, currentState.errorToasts, currentState.forbiddenText)
685
+ expectOrderedIDs(expectedPartIDs, currentState.ids, "mounted part")
686
+ expectOrderedIDs(expectedPartIDs, currentState.visibleIds, "visible part")
687
+ expectOrderedIDs(expectedMessageIDs, unique(currentState.messageIds), "mounted message")
688
+ expectOrderedIDs(expectedMessageIDs, unique(currentState.visibleMessageIds), "visible message")
689
+ }
690
+
691
+ function expectCompleteScroll(
692
+ state: SmokeState,
693
+ expectedPartIDs: string[],
694
+ expectedMessageIDs: string[],
695
+ seenParts: Set<string>,
696
+ seenMessages: Set<string>,
697
+ samples: TraversalSample[],
698
+ ) {
699
+ expect(state.scrollTop, `timeline should reach the start\n${sampleSummary(samples)}`).toBeLessThanOrEqual(1)
700
+ expect(
701
+ expectedPartIDs.filter((id) => !seenParts.has(id)),
702
+ `missing visible timeline parts\n${sampleSummary(samples)}`,
703
+ ).toEqual([])
704
+ expect(
705
+ expectedMessageIDs.filter((id) => !seenMessages.has(id)),
706
+ `missing visible messages\n${sampleSummary(samples)}`,
707
+ ).toEqual([])
708
+ expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length)
709
+ expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length)
710
+ expect(expectedPartIDs.length).toBe(331)
711
+ }
712
+
713
+ async function selectHomeProject(page: Page, projectName: string) {
714
+ await page.goto("/")
715
+ const row = page
716
+ .locator('[data-component="home-project-row"]')
717
+ .filter({ hasText: new RegExp(projectName, "i") })
718
+ .first()
719
+ await expectAppVisible(row)
720
+ await row.click()
721
+ await expect(row).toHaveAttribute("data-selected", "", { timeout: APP_READY_TIMEOUT })
722
+ await expect(page).toHaveURL(/\/$/)
723
+ }
724
+
725
+ async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) {
726
+ await page.goto(`/${base64Encode(directory)}/session/${sessionId}`)
727
+ await expectSessionTitle(page, expectedTitle)
728
+ }
729
+
730
+ async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
731
+ const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`
732
+ const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
733
+ await expect(tab).toBeVisible()
734
+ await tab.click()
735
+ await expectSessionTitle(page, title)
736
+ }
737
+
738
+ async function expectSessionReady(page: Page) {
739
+ await expectAppVisible(page.getByRole("textbox", { name: "Prompt" }))
740
+ }