Compare commits

...

11 Commits

Author SHA1 Message Date
Brendonovich 77fdb42d8c test(ci): select component suites by affected dependencies 2026-08-26 17:29:39 +00:00
Brendonovich 08e60630fe test(app): restore assertion and integration coverage 2026-08-26 17:07:15 +00:00
Brendonovich 7a2adc3a08 chore(app): merge v2 and preserve component coverage 2026-08-26 16:18:43 +00:00
Brendonovich 7d79b9c70c test(session-ui): consolidate stories into five product scenarios 2026-08-26 12:26:19 +00:00
Brendonovich 7800ed86dd test(session-ui): consolidate meaningful component stories 2026-08-26 07:26:36 +00:00
Brendonovich 8228fca696 test(app): colocate component suites with package owners 2026-08-26 04:12:25 +00:00
Brendonovich 4308b6ba26 test(app): finish isolating component-level regressions 2026-08-26 03:47:41 +00:00
Brendonovich 034fb71c44 test(app): checkpoint remaining component migrations 2026-08-26 03:33:23 +00:00
Brendonovich e60ebb5645 test(app): migrate more storybook component cases 2026-08-26 03:19:46 +00:00
Brendonovich 409bf6ea13 test(app): trace migrated component test origins 2026-08-26 03:10:59 +00:00
Brendonovich 4b627a740d test(app): isolate component coverage with storybook 2026-08-26 03:05:18 +00:00
48 changed files with 2440 additions and 583 deletions
+59 -7
View File
@@ -27,6 +27,8 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
app: ${{ steps.packages.outputs.app }}
appComponents: ${{ steps.packages.outputs.appComponents }}
sessionComponents: ${{ steps.packages.outputs.sessionComponents }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
@@ -39,18 +41,16 @@ jobs:
with:
bun-version-file: package.json
- name: Test browser suite selection
working-directory: script/github
run: bun test --root . browser-suites.test.ts
- name: Find affected packages
id: packages
env:
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "app=true" >> "$GITHUB_OUTPUT"
exit 0
fi
bun x turbo@2.10.2 ls --affected --filter=@opencode-ai/app --output=json > affected.json
bun -e 'const result = await Bun.file("affected.json").json(); console.log(`app=${result.packages.count > 0}`)' >> "$GITHUB_OUTPUT"
run: bun script/github/browser-suites.ts
unit:
name: unit (${{ matrix.settings.name }})
@@ -176,6 +176,58 @@ jobs:
working-directory: packages/www
run: bun run check:generated
components:
name: components (${{ matrix.package }})
needs: affected
strategy:
fail-fast: false
matrix:
include:
- package: app
enabled: ${{ needs.affected.outputs.appComponents }}
- package: session-ui
enabled: ${{ needs.affected.outputs.sessionComponents }}
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
# Separate runners own separate Storybook servers; the suites never race for a port.
- name: Checkout repository
if: matrix.enabled == 'true'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Setup Bun
if: matrix.enabled == 'true'
uses: ./.github/actions/setup-bun
- name: Setup Node for Playwright
if: matrix.enabled == 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "24.15"
- name: Install Chromium
if: matrix.enabled == 'true'
working-directory: packages/${{ matrix.package }}
run: bunx playwright install --with-deps chromium
- name: Run component tests
if: matrix.enabled == 'true'
working-directory: packages/${{ matrix.package }}
run: bun run test:components
timeout-minutes: 15
env:
CI: true
- name: Upload component artifacts
if: always() && matrix.enabled == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: components-${{ matrix.package }}-${{ github.run_attempt }}
if-no-files-found: ignore
retention-days: 7
path: |
packages/${{ matrix.package }}/component-tests/test-results
packages/${{ matrix.package }}/component-tests/playwright-report
e2e:
name: e2e (${{ matrix.settings.name }})
needs: affected
+2
View File
@@ -731,6 +731,7 @@
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@playwright/test": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
@@ -836,6 +837,7 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@playwright/test": "catalog:",
"@solidjs/meta": "catalog:",
"@storybook/addon-a11y": "10.4.4",
"@storybook/addon-docs": "10.4.4",
+2
View File
@@ -1,3 +1,5 @@
src/assets/theme.css
e2e/test-results
e2e/playwright-report
component-tests/test-results
component-tests/playwright-report
@@ -0,0 +1,26 @@
import { expect, story } from "../../storybook/playwright/story"
// Moved from packages/app/e2e/regression/prompt-thinking-level.spec.ts
story("shows the thinking level control while relevant", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--model-and-variant")
const composer = component.locator('[data-component="composer"]')
const input = composer.locator('[data-component="composer-editor"]')
const control = composer.getByRole("button", { name: "Choose model variant" })
await page.mouse.move(0, 0)
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
await expect(control).toBeVisible()
await control.click()
const high = page.getByRole("menuitemradio", { name: "high" })
await expect(high).toBeVisible()
await page.mouse.move(0, 0)
await expect(control).toBeVisible()
await expect(high).toBeVisible()
await high.click()
await input.focus()
await expect(control).toBeVisible()
await input.blur()
await expect(control).toBeVisible()
})
@@ -12,54 +12,6 @@ test.beforeEach(async ({ page }) => {
await openReview(page)
})
test("opens the comment editor when code is clicked", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const line = review.getByText("export const value = 'after'", { exact: true })
await expectAppVisible(line)
await line.click()
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
})
test("opens the comment editor when a line number is clicked", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const lineNumber = review.locator('[data-column-number="1"]').last()
await expectAppVisible(lineNumber)
await lineNumber.click()
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
test("opens the comment editor for a line number range", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const start = review.locator('[data-column-number="1"]').last()
const end = review.locator('[data-column-number="3"]').last()
await expectAppVisible(start)
await expectAppVisible(end)
await start.dragTo(end)
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
})
test("shows a comment button when a diff line is hovered", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const line = review.getByText("export const first = 1", { exact: true })
await expectAppVisible(line)
const comment = review.getByRole("button", { name: "Comment", exact: true, includeHidden: true })
await expect(comment).toHaveCount(1)
await line.dispatchEvent("pointermove", { pointerType: "mouse", bubbles: true, composed: true })
await expect(comment).toBeVisible()
await expect(comment).toHaveCSS("pointer-events", "auto")
await comment.dispatchEvent("click")
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
test("stages a submitted line comment in the prompt context", async ({ page }) => {
page.on("request", (request) => {
expect.soft(request.method(), `unexpected ${request.method()} ${new URL(request.url()).pathname}`).toBe("GET")
@@ -201,12 +201,15 @@ test("editing restores the existing draft and replaces only the original queue p
await view.input.fill("my in-progress draft")
await original.click()
await expect(view.input).toHaveText("tighten the error copy")
await expect(view.input).toBeFocused()
await view.input.press("Escape")
await expect(view.input).toHaveText("my in-progress draft")
await original.click()
await expect(view.input).toHaveText("tighten the error copy")
await expect(view.input).toBeFocused()
await view.input.fill("tighten the error copy and add a retry hint")
await expect(view.input).toHaveText("tighten the error copy and add a retry hint")
await view.input.press("Enter")
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([
@@ -1,16 +1,43 @@
import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, shell, userMessage } from "../performance/timeline-stability/fixture"
import {
assistantMessage,
setupTimeline,
shell,
textPart,
userMessage,
} from "../performance/timeline-stability/fixture"
test("space activates a focused timeline button instead of scrolling", async ({ page }) => {
const shellID = "prt_space_button_shell"
await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
messages: [
userMessage(),
assistantMessage([
shell(shellID, "completed", lines(5)),
textPart(
"prt_space_following",
"Following content leaves room to focus the command away from the bottom. ".repeat(40),
),
]),
],
settings: { shellToolPartsExpanded: false },
reducedMotion: true,
seedHistory: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const trigger = page.getByRole("button", { name: "Used Shell" })
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
.toBeGreaterThan(300)
await trigger.scrollIntoViewIfNeeded()
await scroller.hover()
await page.mouse.wheel(0, -100)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeGreaterThan(50)
await expect(trigger).toBeInViewport()
await trigger.focus()
await expect(trigger).toBeFocused()
const before = await scroller.evaluate((element) => element.scrollTop)
await trigger.press("Space")
await expect(trigger).toHaveAttribute("aria-expanded", "true")
@@ -8,21 +8,6 @@ import {
userText,
} from "../performance/timeline-stability/fixture"
test("renders completed write content", async ({ page }) => {
const id = "prt_file_projection_write"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(id, "write", "completed", { path: "src/write.ts", content: "export const written = true\n" }),
]),
],
settings: { editToolPartsExpanded: true },
})
await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="write-content"]`)).toBeVisible()
})
test("renders a completed single-file patch", async ({ page }) => {
const id = "prt_file_projection_single_patch"
await setupTimeline(page, {
@@ -1,53 +0,0 @@
import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
import { createTwoFilesPatch } from "diff"
test("keeps patch file disclosures independent", async ({ page }) => {
const patchID = "prt_nested_patch"
const files = [patchFile("src/a.ts", "modified"), patchFile("src/b.ts", "added"), patchFile("src/old.ts", "deleted")]
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
patchID,
"patch",
"completed",
{ patchText: "Update three files" },
{ metadata: { files } },
),
]),
],
settings: { editToolPartsExpanded: true },
})
const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`)
const modified = wrapper.locator('[data-scope="apply-patch"] [data-type="update"]')
const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"]')
await expect(wrapper.locator('[data-scope="apply-patch"] [aria-expanded="false"]')).toHaveCount(3)
await deleted.getByRole("button").click()
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await modified.getByRole("button").click()
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await deleted.getByRole("button").click()
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true")
})
function patchFile(file: string, status: "added" | "modified" | "deleted") {
const before = status === "added" ? "" : source(false)
const after = status === "deleted" ? "" : source(true)
return {
file,
status,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
additions: status === "deleted" ? 0 : 4,
deletions: status === "added" ? 0 : 3,
}
}
function source(changed: boolean) {
return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
"",
)
}
@@ -122,6 +122,7 @@ test("transitions thinking and hidden reasoning through busy to idle", async ({
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0)
await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180)
@@ -129,6 +130,7 @@ test("transitions thinking and hidden reasoning through busy to idle", async ({
await timeline.send(status("idle"), 300)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0)
})
test("moves busy through retry and recovery to final idle content", async ({ page }) => {
@@ -122,6 +122,7 @@ test("updates running compactions to failed and cancelled boundaries", async ({
await timeline.send(compactionStarted({ sessionID, reason: "auto", recent: "" }))
await timeline.send(compactionDelta({ sessionID, text: "Partial summary that should be discarded." }))
await expect(page.getByText("Partial summary that should be discarded.", { exact: true })).toBeVisible()
await timeline.send(
compactionFailed({
sessionID,
@@ -140,6 +141,9 @@ test("updates running compactions to failed and cancelled boundaries", async ({
await expect(failed).not.toContainText("Partial summary that should be discarded.")
await timeline.send(compactionStarted({ sessionID, reason: "manual", recent: "" }))
await expect(compactions).toHaveCount(2)
await timeline.send(compactionDelta({ sessionID, text: "Summary before cancellation." }))
await expect(page.getByText("Summary before cancellation.", { exact: true })).toBeVisible()
await timeline.send(
compactionFailed({
sessionID,
@@ -152,88 +156,7 @@ test("updates running compactions to failed and cancelled boundaries", async ({
const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." })
await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.")
})
test("shows a delegating row while subagent input streams", async ({ page }) => {
await setupTimeline(page, {
sessionMessages: [
user,
{
...assistant(false),
content: [
{
type: "tool",
id: "call_subagent",
name: "subagent",
state: { status: "streaming", input: "" },
time: { created: 2 },
},
],
},
],
})
const delegating = page.locator('[data-component="task-tool-delegating"]')
await expect(delegating).toBeVisible()
const shimmer = delegating.locator('[data-component="text-shimmer"]')
await expect(shimmer).toHaveAttribute("aria-label", "Delegating agent...")
await expect(shimmer).toHaveCSS("line-height", "16px")
const icon = delegating.locator('[data-slot="icon-svg"]')
await expect(icon.locator('use[href="#opencode-v2-icon-subagent"]')).toBeVisible()
await expect(icon).toHaveCSS("color", "rgb(174, 174, 174)")
await expect(page.locator('[data-component="task-tool-card"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
})
test("renders the moved location notice in its compact timeline style", async ({ page }) => {
const directory = `/Users/usrnk1/Developer/opencode/${"nested-directory/".repeat(24)}session`
await page.setViewportSize({ width: 480, height: 720 })
await setupTimeline(page, {
sessionMessages: [
user,
{
id: "msg_location",
type: "location-switched",
location: { directory },
time: { created: 2 },
},
],
})
const notice = page.locator('[data-slot="session-timeline-notice"][data-type="location-switched"]')
const label = notice.locator('[data-slot="session-timeline-notice-label"]')
const value = notice.locator('[data-slot="session-timeline-notice-value"]')
const tooltipTrigger = notice.locator('[data-component="tooltip-v2-trigger"]')
await expect(label).toHaveText("Moved to")
await expect(value).toHaveText(directory)
await expect(notice).not.toContainText("·")
await expect(notice.locator("svg")).toHaveCount(0)
await expect(notice).toHaveCSS("height", "28px")
await expect(notice).toHaveCSS("gap", "8px")
await expect(notice).toHaveCSS("padding-top", "4px")
await expect(notice).toHaveCSS("padding-bottom", "4px")
await expect(label).toHaveCSS("font-size", "13px")
await expect(label).toHaveCSS("font-weight", "530")
await expect(label).toHaveCSS("line-height", "16px")
await expect(label).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("font-size", "13px")
await expect(value).toHaveCSS("font-weight", "440")
await expect(value).toHaveCSS("line-height", "16px")
await expect(value).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("text-overflow", "ellipsis")
await expect(value).toHaveCSS("white-space", "nowrap")
await expect(value).toHaveAttribute("dir", "ltr")
await expect.poll(() => value.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
const tooltip = page.getByText("Session working directory changed", { exact: true })
await label.hover()
await expect(tooltip).toBeVisible()
await page.mouse.move(0, 0)
await expect(tooltip).toBeHidden()
await tooltipTrigger.focus()
await expect(tooltipTrigger).toBeFocused()
await expect(tooltip).toBeVisible()
await expect(cancelled).not.toContainText("Summary before cancellation.")
})
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
@@ -271,11 +194,6 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await request
})
test("waits for completion before labeling requested background work", async ({ page }) => {
await setupTimeline(page, { sessionMessages: [user, assistant(false, true, undefined, true)] })
await expect(page.locator('[data-component="task-tool-card"]')).not.toContainText("(background)")
})
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
const childID = "ses_running_child"
await setupTimeline(page, {
@@ -7,86 +7,9 @@ import {
toolPart,
userMessage,
userText,
type PartSeed,
} from "../performance/timeline-stability/fixture"
test.describe("session timeline projection", () => {
test("renders every admitted tool family and hides timeline-only exclusions", async ({ page }) => {
const parts = [
toolPart("prt_01_read", "read", "completed", { path: "src/a.ts" }),
toolPart("prt_02_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
toolPart("prt_03_grep", "grep", "completed", { path: ".", pattern: "value" }),
toolPart("prt_04_list", "list", "completed", { path: "src" }),
toolPart("prt_webfetch", "webfetch", "completed", { url: "https://example.com" }),
toolPart(
"prt_websearch",
"websearch",
"completed",
{ query: "timeline stability" },
{ output: "https://example.com/result" },
),
toolPart("prt_task", "subagent", "completed", {
description: "Inspect timeline",
agent: "explore",
prompt: "Inspect the timeline implementation.",
}),
toolPart(
"prt_bash",
"shell",
"completed",
{ command: "printf stable" },
{ output: "stable", title: "printf stable" },
),
editPart("prt_edit"),
toolPart("prt_write", "write", "completed", { path: "src/new.ts", content: "export const stable = true\n" }),
patchPart("prt_patch"),
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
toolPart(
"prt_question",
"question",
"completed",
{ questions: [{ question: "Keep stable?", header: "Stability", options: [] }] },
{ metadata: { answers: [["Yes"]] } },
),
toolPart("prt_skill", "skill", "completed", { name: "stability" }),
toolPart("prt_custom", "custom_mcp_tool", "completed", { target: "timeline", count: 2 }),
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const first = page.locator(
'[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list,prt_webfetch,prt_websearch,prt_task,prt_bash,prt_edit,prt_write,prt_patch"]',
)
const second = page.locator('[data-timeline-part-ids="prt_skill,prt_custom"]')
await expect(first).toBeVisible()
await expect(second).toBeVisible()
await first.getByRole("button").click()
await second.getByRole("button").click()
for (const id of [
"prt_webfetch",
"prt_websearch",
"prt_task",
"prt_bash",
"prt_edit",
"prt_write",
"prt_patch",
"prt_question",
"prt_skill",
"prt_custom",
]) {
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
}
const patch = page.locator('[data-timeline-part-id="prt_patch"]')
await expect(patch.getByText("1 file", { exact: true })).toBeVisible()
await expect(patch.getByRole("button", { name: "Patch 1 file", exact: true })).toHaveCount(0)
await expect(patch.getByRole("button")).toHaveCount(1)
await expect(patch.locator('[data-scope="apply-patch"] button[aria-expanded="false"]')).toHaveCount(1)
await expect(patch.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0)
await expect(patch.locator('[data-slot="message-part-actions"]')).toHaveCount(0)
const edit = page.locator('[data-timeline-part-id="prt_edit"]')
await expect(edit).toContainText("Edit")
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
})
test("combines adjacent patch calls and repeated files into one group", async ({ page }) => {
const first = "prt_patch_first"
const second = "prt_patch_second"
@@ -158,43 +81,6 @@ test.describe("session timeline projection", () => {
await expect(page.locator(`[data-timeline-part-id="${first}"], [data-timeline-part-id="${second}"]`)).toHaveCount(0)
})
test("combines adjacent edit calls and repeated files into one group", async ({ page }) => {
const first = "prt_edit_first"
const second = "prt_edit_second"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
first,
"edit",
"completed",
{ path: "src/first.ts", oldString: "one", newString: "two" },
{
metadata: { files: [patchFile("src/first.ts", "modified")] },
},
),
toolPart(
second,
"edit",
"completed",
{ path: "src/first.ts", oldString: "two", newString: "three" },
{
metadata: { files: [patchFile("src/first.ts", "modified")] },
},
),
]),
],
settings: { editToolPartsExpanded: true },
})
const group = page.locator(`[data-timeline-part-ids="${first},${second}"]`)
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit")
await expect(group.getByText("1 file", { exact: true })).toBeVisible()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts"])
await expect(group.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
})
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
const firstUser = userMessage(
[
@@ -236,25 +122,6 @@ test.describe("session timeline projection", () => {
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
})
test("renders interruption independently when the turn is not compacted", async ({ page }) => {
const user = userMessage()
const before = assistantMessage([{ id: "prt_before", type: "text", text: "Before" }], {
id: "msg_1001_before",
error: { type: "MessageAbortedError", message: "Stopped" },
})
const after = assistantMessage([{ id: "prt_after", type: "text", text: "After" }], {
id: "msg_1002_after",
created: 1700000003000,
})
await setupTimeline(page, { messages: [user, before, after] })
await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
const rows = await page
.locator('[data-timeline-row="AssistantPart"], [data-timeline-row="TurnDivider"]')
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-row")))
expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"])
})
test("renders aliased and long custom model notices", async ({ page }) => {
const shortName = "GPT-5.4 nano"
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
@@ -291,77 +158,8 @@ test.describe("session timeline projection", () => {
await expect(longNotice.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
await expect.poll(() => longNotice.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
})
test("renders user image, file attachment, file reference, and agent reference", async ({ page }) => {
const text = "Use @explore with @src/a.ts and inspect the attachments"
const parts: PartSeed<"user">[] = [
userText(text, { id: "prt_user_rich" }),
{
id: "prt_user_image",
type: "file",
mime: "image/png",
filename: "pixel.png",
url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
},
{
id: "prt_user_attachment",
type: "file",
mime: "application/json",
filename: "tsconfig.json",
url: "data:application/json;base64,e30=",
},
{
id: "prt_user_reference",
type: "file",
mime: "text/plain",
filename: "a.ts",
url: "src/a.ts",
source: { type: "file", path: "src/a.ts", text: { value: "@src/a.ts", start: 18, end: 27 } },
},
{
id: "prt_user_agent",
type: "agent",
name: "explore",
source: { value: "@explore", start: 4, end: 12 },
},
]
await setupTimeline(page, { messages: [userMessage(parts), assistantMessage()] })
await expect(page.getByAltText("pixel.png")).toBeVisible()
await expect(page.getByText("tsconfig.json")).toBeVisible()
await expect(page.getByText("@src/a.ts", { exact: true })).toBeVisible()
await expect(page.getByText("@explore", { exact: true })).toBeVisible()
})
})
function editPart(id: string) {
return toolPart(
id,
"edit",
"completed",
{ path: "src/a.ts", oldString: "export const value = 1", newString: "export const value = 2" },
{
metadata: {
files: [patchFile("src/a.ts", "modified")],
},
},
)
}
function patchPart(id: string) {
return toolPart(
id,
"patch",
"completed",
{ patchText: "Update the projected files" },
{
metadata: {
files: [patchFile("src/a.ts", "modified")],
},
},
)
}
function patchFile(file: string, status: "added" | "modified" | "deleted") {
return {
file,
@@ -7,33 +7,25 @@ import {
renderedPartID,
setupTimeline,
shell,
toolPart,
status,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
test("groups every collapsed tool until visible text separates the stack", async ({ page }) => {
const parts = [
toolPart("prt_boundary_01_read", "read", "completed", { path: "src/a.ts" }),
textPart("prt_boundary_02_text", "Boundary text"),
toolPart("prt_boundary_03_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
toolPart("prt_boundary_04_grep", "grep", "completed", { path: ".", pattern: "stable" }),
shell("prt_boundary_05_shell", "completed", "done"),
toolPart("prt_boundary_06_list", "list", "completed", { path: "src" }),
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
const textID = "prt_event_order_text"
const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
await timeline.send(status("busy"), 100)
await timeline.send(status("idle"), 100)
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 250)
await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
const group = page.locator(
'[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep,prt_boundary_05_shell,prt_boundary_06_list"]',
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText(
"Final after early idle",
)
await expect(group).toBeVisible()
await expect(group.getByRole("button")).toHaveAccessibleName("Used Glob, Grep, Shell, List")
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(3)
await expect(page.locator('[data-timeline-spacing="content"]')).toHaveCount(2)
await expect(page.locator('[data-timeline-spacing="content"]').nth(0)).toHaveCSS("padding-top", "16px")
})
test("expands a mixed collapsed tool stack without expanding its individual calls", async ({ page }) => {
@@ -132,18 +124,3 @@ test("keeps failed search calls and their error cards inside the collapsed stack
"Search timed out after 30 seconds",
)
})
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
const textID = "prt_event_order_text"
const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
await timeline.send(status("busy"), 100)
await timeline.send(status("idle"), 100)
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 250)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText(
"Final after early idle",
)
})
@@ -7,31 +7,6 @@ import {
userMessage,
} from "../performance/timeline-stability/fixture"
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
const ordinary = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"]
const parts = ordinary.map((tool, index) =>
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
)
parts.push(
toolPart("prt_question_dismissed", "question", "error", questionInput(), {
error: "The user dismissed this question",
}),
toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
)
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator(`[data-timeline-part-ids="${ordinary.map((_, index) => `prt_error_${index}`).join(",")}"]`)
await expect(group.locator('[data-component="tag"]')).toHaveText(String(ordinary.length))
await group.getByRole("button").click()
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
await expect(page.getByText(/dismissed/i)).toBeVisible()
await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
for (let index = 0; index < ordinary.length; index++) {
await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
}
})
test("transitions shell and question through running error outcomes", async ({ page }) => {
const shellID = "prt_transition_error_shell"
const questionID = "prt_transition_error_question"
@@ -138,62 +113,6 @@ test("preserves surviving grouped patch state when its first patch fails", async
.toBeGreaterThanOrEqual(-0.5)
})
test("labels all web search provider variants", async ({ page }) => {
const parts = [
toolPart(
"prt_search_parallel",
"websearch",
"completed",
{ query: "parallel" },
{ metadata: { provider: "parallel" } },
),
toolPart("prt_search_exa", "websearch", "completed", { query: "exa" }, { metadata: { provider: "exa" } }),
toolPart("prt_search_generic", "websearch", "completed", { query: "generic" }),
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await page.getByRole("button", { name: "Used Parallel Web Search, Exa Web Search, Web Search" }).click()
const tools = page.locator('[data-component="context-tool-group-list"]')
await expect(tools.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
await expect(tools.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
await expect(tools.getByRole("button", { name: /^Web Search/ })).toBeVisible()
})
test("labels completed searches with result counts", async ({ page }) => {
const glob = "prt_glob_count"
const grep = "prt_grep_count"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(glob, "glob", "completed", { path: ".", pattern: "**/*.ts" }, { metadata: { count: 1 } }),
toolPart(grep, "grep", "completed", { path: ".", pattern: "value" }, { metadata: { matches: 12 } }),
]),
],
})
const group = page.locator(`[data-timeline-part-ids="${glob},${grep}"]`)
await group.locator('[data-slot="collapsible-trigger"]').click()
const rows = group.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
await expect(rows.filter({ hasText: "Glob" })).toContainText("(1 match)")
await expect(rows.filter({ hasText: "Grep" })).toContainText("(12 matches)")
})
test("labels read tools from their path input", async ({ page }) => {
const id = "prt_read_path"
await setupTimeline(page, {
messages: [userMessage(), assistantMessage([toolPart(id, "read", "completed", { path: "src/a.ts" })])],
})
const group = page.locator(`[data-timeline-part-ids="${id}"]`)
await group.locator('[data-slot="collapsible-trigger"]').click()
await expect(
group
.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
.filter({ hasText: "Read" }),
).toContainText("a.ts")
})
test("groups instruction files loaded by the same read", async ({ page }) => {
const id = "prt_read_instructions"
await setupTimeline(page, {
@@ -221,36 +140,6 @@ test("groups instruction files loaded by the same read", async ({ page }) => {
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveCount(0)
})
test("labels skill tools from IDs and result metadata", async ({ page }) => {
const pending = "prt_skill_id"
const completed = "prt_skill_name"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(pending, "skill", "running", { id: "frontend-design" }),
toolPart(completed, "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }),
]),
],
})
const group = page.locator(`[data-timeline-part-ids="${pending},${completed}"]`)
await expect(group.getByRole("button")).toHaveAccessibleName("Used Skill")
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await group.getByRole("button").click()
const loaded = group.locator('[data-component="tool-loaded-item"]')
await expect(loaded).toHaveCount(1)
await expect(loaded).toHaveAttribute("aria-label", "Loaded frontend-design, OpenCode skills")
await expect(loaded).toHaveCSS("line-height", "16px")
await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded")
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skills")
const names = loaded.locator('[data-component="text-shimmer"]')
await expect(names).toHaveCount(2)
await expect(names.nth(0)).toHaveAttribute("aria-label", "frontend-design")
await expect(names.nth(1)).toHaveAttribute("aria-label", "OpenCode")
})
test("groups only consecutive successful skill tools", async ({ page }) => {
const parts = [
toolPart("prt_skill_first", "skill", "completed", { id: "ocpr" }),
@@ -273,14 +162,3 @@ test("groups only consecutive successful skill tools", async ({ page }) => {
function questionInput() {
return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
}
function errorInput(tool: string) {
if (tool === "shell") return { command: "exit 1" }
if (["edit", "write"].includes(tool)) return { path: "src/error.ts", content: "" }
if (tool === "patch") return { patchText: "Update src/error.ts" }
if (tool === "webfetch") return { url: "https://example.com" }
if (tool === "websearch") return { query: "failure" }
if (tool === "subagent") return { description: "Fail subagent", agent: "explore", prompt: "Inspect the failure." }
if (tool === "skill") return { name: "failure" }
return { target: "failure" }
}
@@ -87,6 +87,7 @@ test("clears the terminal line with Command+Delete", async ({ page }) => {
const terminal = page.locator('[data-component="terminal"]')
await page.keyboard.press("Control+Backquote")
await expect(terminal.locator("textarea")).toHaveCount(1)
await expect.poll(() => sendPtyOutput).toBeDefined()
await page.keyboard.press("Meta+Backspace")
+1 -1
View File
@@ -7,5 +7,5 @@
"rootDir": "..",
"types": ["node", "bun"]
},
"include": ["./**/*.ts", "./**/*.tsx", "../src/types.ts"]
"include": ["./**/*.ts", "./**/*.tsx", "../component-tests/**/*.ts", "../src/types.ts"]
}
+2
View File
@@ -26,6 +26,8 @@
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:local": "playwright test",
"test:components": "playwright test --config playwright.components.config.ts",
"test:components:ui": "playwright test --config playwright.components.config.ts --ui",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report e2e/playwright-report",
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
@@ -0,0 +1,4 @@
import { fileURLToPath } from "node:url"
import { componentConfig } from "../storybook/playwright/config"
export default componentConfig(fileURLToPath(new URL(".", import.meta.url)), 6007)
+7 -1
View File
@@ -21,6 +21,12 @@
"@/*": ["./src/*"]
}
},
"include": ["src", "package.json"],
"include": [
"src",
"component-tests",
"playwright.components.config.ts",
"../storybook/playwright/*.ts",
"package.json"
],
"exclude": ["dist", "ts-dist"]
}
+109
View File
@@ -514,6 +514,115 @@ test("event.subscribe exposes the Promise event stream wire projection", async (
expect(events[1]?.type === "session.model.selected" && events[1].created).toBe(1_717_171_717_000)
})
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
test("event.subscribe keeps one request open while delivering multiple events", async () => {
const requests: Request[] = []
const events = [
{ id: "evt_first", created: 1, type: "server.connected", data: {} },
{ id: "evt_second", created: 2, type: "server.connected", data: {} },
]
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
requests.push(input instanceof Request ? input : new Request(input, init))
return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
headers: { "content-type": "text/event-stream" },
})
},
})
const received = []
for await (const event of client.event.subscribe()) received.push(event)
expect(received).toEqual(events)
expect(requests).toHaveLength(1)
})
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
test("event.subscribe delivers every event from one stream chunk", async () => {
const events = Array.from({ length: 4 }, (_, index) => ({
id: `evt_burst_${index}`,
created: index,
type: "server.connected",
data: {},
}))
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
new Response(new TextEncoder().encode(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("")), {
headers: { "content-type": "text/event-stream" },
}),
})
const received = []
for await (const event of client.event.subscribe()) received.push(event)
expect(received).toEqual(events)
expect(new Set(received.map((event) => event.id)).size).toBe(4)
})
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
test("event.subscribe parses split JSON and a split multibyte code point", async () => {
const event = {
id: "evt_split",
created: 1,
type: "server.connected",
data: { text: "split snowman \u2603\u2603\u2603" },
}
const encoded = new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)
const multibyte = encoded.indexOf(new TextEncoder().encode("\u2603")[0]!)
const boundaries = [9, multibyte + 1, multibyte + 2, encoded.length]
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
new Response(
new ReadableStream({
start(controller) {
boundaries.forEach((end, index) =>
controller.enqueue(encoded.slice(index ? boundaries[index - 1] : 0, end)),
)
controller.close()
},
}),
{ headers: { "content-type": "text/event-stream" } },
),
})
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).resolves.toEqual({ done: false, value: event })
})
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
test("event.subscribe ignores server heartbeat comments", async () => {
const event = { id: "evt_sentinel", created: 1, type: "server.connected", data: {} }
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
new Response(`: heartbeat\n\ndata: ${JSON.stringify(event)}\n\n: heartbeat\n\n`, {
headers: { "content-type": "text/event-stream" },
}),
})
const received = []
for await (const item of client.event.subscribe()) received.push(item)
expect(received).toEqual([event])
})
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
test("event transport passes through ordinary health requests", async () => {
const requests: string[] = []
const event = { id: "evt_connected", created: 1, type: "server.connected", data: {} }
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push(new URL(request.url).pathname)
if (new URL(request.url).pathname === "/api/event") {
return new Response(`data: ${JSON.stringify(event)}\n\n`, {
headers: { "content-type": "text/event-stream" },
})
}
return Response.json({ healthy: true, version: "2.0.0", pid: 1 })
},
})
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).resolves.toEqual({ done: false, value: event })
await expect(client.health.get()).resolves.toEqual({ healthy: true, version: "2.0.0", pid: 1 })
expect(requests).toEqual(["/api/event", "/api/health"])
})
test("event.subscribe terminates on malformed Promise SSE data", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
+2
View File
@@ -0,0 +1,2 @@
component-tests/test-results
component-tests/playwright-report
@@ -0,0 +1,127 @@
import { expect, story } from "../../storybook/playwright/story"
for (const expanded of [false, true]) {
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
story(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ mount }) => {
const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { expanded } })
const trigger = expanded
? timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"] [data-slot="collapsible-trigger"]')
: timeline.getByRole("button", { name: "Used Shell", exact: true })
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
await timeline.getByRole("button", { name: "Update output" }).click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
await timeline.getByRole("button", { name: "Append sibling" }).click()
await expect(timeline.getByText("Sibling content", { exact: true })).toBeVisible()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
await timeline.getByRole("button", { name: "Mark session busy" }).click()
await timeline.getByRole("button", { name: "Mark session idle" }).click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
})
}
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
story("transitions a streaming shell from writing through command execution", async ({ mount }) => {
const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { streaming: true } })
const tool = timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"]')
const title = tool.locator('[data-slot="basic-tool-tool-title"]')
const shimmer = title.locator('[data-component="text-shimmer"]')
const subtitle = tool.locator('[data-slot="basic-tool-tool-subtitle"]')
await expect(shimmer).toHaveAttribute("aria-label", "Shell")
await expect(shimmer).toHaveAttribute("data-active", "true")
await expect(subtitle).toHaveText("Writing command...")
await expect(subtitle.locator('[data-component="text-shimmer"]')).toHaveCount(0)
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveCount(0)
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px")
await expect(tool.locator('[data-component="tool-trigger"]')).toHaveCSS("gap", "6px")
await expect(title).toHaveCSS("font-size", "13px")
await expect(title).toHaveCSS("font-family", /^Inter,/)
await expect(title).toHaveCSS("font-weight", "530")
await expect(title).toHaveCSS("line-height", "16px")
await expect(title).toHaveCSS("color", "rgb(22, 22, 22)")
await expect(subtitle).toHaveCSS("font-size", "13px")
await expect(subtitle).toHaveCSS("font-family", /^Inter,/)
await expect(subtitle).toHaveCSS("font-weight", "440")
await expect(subtitle).toHaveCSS("line-height", "16px")
await expect(subtitle).toHaveCSS("color", "rgb(92, 92, 92)")
await timeline.getByRole("button", { name: "Complete input" }).click()
await expect(shimmer).toHaveAttribute("data-active", "true")
await expect(subtitle).toHaveText("printf ready")
await expect(tool).not.toContainText("Writing command...")
await timeline.getByRole("button", { name: "Run command" }).click()
await expect(shimmer).toHaveAttribute("data-active", "true")
await expect(subtitle).toHaveText("printf ready")
await expect(tool).not.toContainText("Writing command...")
await timeline.getByRole("button", { name: "Complete command" }).click()
const summary = timeline.getByRole("button", { name: "Used Shell", exact: true })
await expect(summary).toHaveAttribute("aria-expanded", "false")
await summary.click()
await expect(subtitle).toHaveText("printf ready")
})
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
story("shimmers and expands a running shell command", async ({ mount }) => {
const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { streaming: true } })
await timeline.getByRole("button", { name: "Run command" }).click()
const tool = timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"]')
const trigger = tool.locator('[data-slot="collapsible-trigger"]')
await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
await expect(tool).not.toContainText("Writing command...")
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText("printf ready")
await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0)
await expect(trigger).toHaveCSS("height", "28px")
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
})
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
story("transitions thinking and hidden reasoning through busy to idle", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "hidden" } })
const reasoning = timeline.locator('[data-timeline-part-id="msg_hidden_reasoning_lifecycle:reasoning:0"]')
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeVisible()
await expect(reasoning).toHaveCount(0)
await timeline.getByRole("button", { name: "Start shell" }).click()
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(timeline.locator('[data-timeline-part-id="tool_hidden_reasoning_shell"]')).toBeVisible()
await timeline.getByRole("button", { name: "Finish session" }).click()
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(reasoning).toHaveCount(0)
})
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
story("moves busy through retry and recovery to final idle content", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "retry" } })
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(timeline.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
await timeline.getByRole("button", { name: "Retry request" }).click()
await expect(timeline.locator('[data-timeline-row="Retry"]')).toBeVisible()
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await timeline.getByRole("button", { name: "Recover request" }).click()
await expect(timeline.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await timeline.getByRole("button", { name: "Finish response" }).click()
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(timeline.locator('[data-timeline-part-id="msg_retry_recovery_lifecycle:text:0"]')).toContainText(
"Recovered response",
)
})
for (const locale of ["de", "ar"] as const) {
// Moved from packages/app/e2e/regression/session-timeline-locale-projection.spec.ts
story(`projects localized tool names with an English fallback in ${locale}`, async ({ mount, page }) => {
const timeline = await mount("current-session-research-agents--agent-research", {
args: { scenario: "exploration" },
globals: { locale },
})
await timeline.getByRole("button", { name: "Complete read" }).click()
await timeline.getByRole("button", { name: "Complete glob" }).click()
const group = timeline.locator('[data-timeline-part-ids="tool_context_read,tool_context_glob"]')
await expect(group.getByRole("button")).toHaveAccessibleName(/^Used /)
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await expect(page.locator("html")).toHaveAttribute("lang", locale)
})
}
@@ -0,0 +1,61 @@
import { expect, story } from "../../storybook/playwright/story"
// Moved from packages/app/e2e/regression/session-timeline-collapse-state.spec.ts
story("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ mount }) => {
const timeline = await mount("current-session-file-changes--changing-files", { args: { scenario: "streaming" } })
const tool = timeline.locator('[data-timeline-part-id="tool_edit_status"]')
const trigger = tool.locator('[data-scope="apply-patch"] button')
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await tool.evaluate((element) => ((element as HTMLElement).dataset.regressionMarker = "before-stream"))
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await timeline.getByRole("button", { name: "Stream sibling content" }).click()
await expect(timeline.getByText("Streaming added a later assistant text part.", { exact: true })).toBeVisible()
await expect(tool).toHaveAttribute("data-regression-marker", "before-stream")
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(tool.locator("xpath=ancestor::*[@data-timeline-row]")).toHaveAttribute(
"data-timeline-row",
"AssistantPart",
)
})
// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts
story("renders interruption independently when the turn is not compacted", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "interruption" } })
await expect(timeline.getByText("Interrupted", { exact: true })).toBeVisible()
await expect(timeline.getByText("Before", { exact: true })).toBeVisible()
await expect(timeline.getByText("After", { exact: true })).toBeVisible()
const rows = await timeline
.locator('[data-timeline-row="AssistantPart"], [data-timeline-row="TurnDivider"]')
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-row")))
expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"])
})
// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts
story("renders aliased and long custom model notices", async ({ mount, page }) => {
await page.setViewportSize({ width: 420, height: 700 })
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "models" } })
const shortName = "GPT-5.4 nano"
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
const short = timeline.locator('[data-slot="session-timeline-notice"]').filter({ hasText: shortName })
const long = timeline.locator('[data-slot="session-timeline-notice"]').filter({ hasText: longName })
await expect(short).toBeVisible()
await expect(short.getByText(`Switched to ${shortName}`, { exact: true })).toBeVisible()
await expect(short.locator('[data-slot="session-timeline-notice-variant"]')).toHaveText("xhigh")
await expect(timeline.getByText("fast-nano", { exact: true })).toHaveCount(0)
await expect(short.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(long).toBeVisible()
await expect(long.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(long.locator('[data-slot="session-timeline-notice-variant"]')).toHaveCount(0)
await expect(long.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
await expect.poll(() => long.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
})
// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts
story("renders user image, file attachment, file reference, and agent reference", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "attachments" } })
await expect(timeline.getByAltText("pixel.png")).toBeVisible()
await expect(timeline.getByText("tsconfig.json")).toBeVisible()
await expect(timeline.getByText("@src/a.ts", { exact: true })).toBeVisible()
await expect(timeline.getByText("@explore", { exact: true })).toBeVisible()
})
@@ -0,0 +1,72 @@
import { expect, story } from "../../storybook/playwright/story"
// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts
story("renders current protocol notices in CLI order", async ({ mount, page }) => {
const warnings: string[] = []
page.on("console", (message) => {
if (message.text().includes("computations created outside a `createRoot` or `render`"))
warnings.push(message.text())
})
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "notices" } })
const notices = timeline.locator('[data-slot="session-timeline-notice"]')
await expect(notices).toHaveCount(4)
await expect(notices.nth(0)).toContainText("Agent · explore")
await expect(notices.nth(1)).toContainText("explore finished · Search code")
await expect(notices.nth(2)).toContainText("Continuing after restart")
await expect(notices.nth(3)).toContainText("Skill · Review")
await expect(notices).toHaveClass([/text-text-weak/, /text-text-weak/, /text-text-weak/, /text-text-weak/])
await expect(notices.locator(".text-text-strong")).toHaveCount(0)
expect(warnings).toEqual([])
})
// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts
story("renders a compaction summary while it streams and after completion", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "compaction" } })
const compaction = timeline.locator('[data-component="session-compaction-message"]')
await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible()
await timeline.getByRole("button", { name: "Stream summary" }).click()
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
await expect(compaction).toContainText("Streamed implementation details.")
await timeline.getByRole("button", { name: "Complete summary" }).click()
await expect(compaction).toContainText("Final implementation details.")
await expect(compaction).not.toContainText("Streamed implementation details.")
})
// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts
story("updates running compactions to failed and cancelled boundaries", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "compaction" } })
await timeline.getByRole("button", { name: "Stream summary" }).click()
await timeline.getByRole("button", { name: "Fail compaction" }).click()
const compactions = timeline.locator('[data-component="session-compaction-message"]')
const failed = compactions.filter({ hasText: "The provider rejected the summary." })
await expect(failed.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(failed.getByText("ProviderError: The provider rejected the summary.", { exact: true })).toBeVisible()
await expect(failed).not.toContainText("Streamed implementation details.")
await timeline.getByRole("button", { name: "Cancel next compaction" }).click()
await expect(compactions).toHaveCount(2)
const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." })
await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.")
})
// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts
story("shows a delegating row while subagent input streams", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "delegation" } })
const delegating = timeline.locator('[data-component="task-tool-delegating"]')
await expect(delegating).toBeVisible()
const shimmer = delegating.locator('[data-component="text-shimmer"]')
await expect(shimmer).toHaveAttribute("aria-label", "Delegating agent...")
await expect(shimmer).toHaveCSS("line-height", "16px")
const icon = delegating.locator('[data-slot="icon-svg"]')
await expect(icon.locator('use[href="#opencode-v2-icon-subagent"]')).toBeVisible()
await expect(icon).toHaveCSS("color", "rgb(174, 174, 174)")
await expect(timeline.locator('[data-component="task-tool-card"]')).toHaveCount(0)
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
})
// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts
story("waits for completion before labeling requested background work", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "background" } })
await expect(timeline.locator('[data-component="task-tool-card"]')).toContainText("Inspect code")
await expect(timeline.locator('[data-component="task-tool-card"]')).not.toContainText("(background)")
})
@@ -0,0 +1,50 @@
import { expect, story } from "../../storybook/playwright/story"
// Moved from packages/app/e2e/regression/review-line-comment.spec.ts
story("opens the comment editor when code is clicked", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments")
const review = root.locator('[data-component="session-review"]')
await review.getByText("export const value = 'after'", { exact: true }).click()
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
})
// Moved from packages/app/e2e/regression/review-line-comment.spec.ts
story("opens the comment editor when a line number is clicked", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments")
const review = root.locator('[data-component="session-review"]')
await expect(review.getByText("export const first = 1", { exact: true })).toBeVisible()
const number = review.locator('[data-column-number="1"]')
await expect(number).toHaveCount(1)
await number.click()
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
// Moved from packages/app/e2e/regression/review-line-comment.spec.ts
story("opens the comment editor for a line number range", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments")
const review = root.locator('[data-component="session-review"]')
const first = review.locator('[data-column-number="1"]')
const last = review.locator('[data-column-number="3"]')
await expect(first).toHaveCount(1)
await expect(last).toHaveCount(1)
await first.dragTo(last)
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
})
// Moved from packages/app/e2e/regression/review-line-comment.spec.ts
story("shows a comment button when a diff line is hovered", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments")
const review = root.locator('[data-component="session-review"]')
const line = review.getByText("export const first = 1", { exact: true })
const comment = review.getByRole("button", { name: "Comment", exact: true, includeHidden: true })
await expect(comment).toHaveCount(1)
await line.hover()
await expect(comment).toBeVisible()
await expect(comment).toHaveCSS("pointer-events", "auto")
await comment.dispatchEvent("click")
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
@@ -0,0 +1,42 @@
import { expect, story } from "../../storybook/playwright/story"
// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts
story("renders the moved location notice in its compact timeline style", async ({ mount, page }) => {
const directory = `/Users/usrnk1/Developer/opencode/${"nested-directory/".repeat(24)}session`
await page.setViewportSize({ width: 480, height: 720 })
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "location" } })
const notice = timeline.locator('[data-slot="session-timeline-notice"][data-type="location-switched"]')
const label = notice.locator('[data-slot="session-timeline-notice-label"]')
const value = notice.locator('[data-slot="session-timeline-notice-value"]')
const tooltipTrigger = notice.locator('[data-component="tooltip-v2-trigger"]')
await expect(label).toHaveText("Moved to")
await expect(value).toHaveText(directory)
await expect(notice).not.toContainText("·")
await expect(notice.locator("svg")).toHaveCount(0)
await expect(notice).toHaveCSS("height", "28px")
await expect(notice).toHaveCSS("gap", "8px")
await expect(notice).toHaveCSS("padding-top", "4px")
await expect(notice).toHaveCSS("padding-bottom", "4px")
await expect(label).toHaveCSS("font-size", "13px")
await expect(label).toHaveCSS("font-weight", "530")
await expect(label).toHaveCSS("line-height", "16px")
await expect(label).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("font-size", "13px")
await expect(value).toHaveCSS("font-weight", "440")
await expect(value).toHaveCSS("line-height", "16px")
await expect(value).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("text-overflow", "ellipsis")
await expect(value).toHaveCSS("white-space", "nowrap")
await expect(value).toHaveAttribute("dir", "ltr")
await expect.poll(() => value.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
const tooltip = page.getByText("Session working directory changed", { exact: true })
await label.hover()
await expect(tooltip).toBeVisible()
await page.mouse.move(0, 0)
await expect(tooltip).toBeHidden()
await tooltipTrigger.focus()
await expect(tooltipTrigger).toBeFocused()
await expect(tooltip).toBeVisible()
})
@@ -0,0 +1,74 @@
import { expect, story } from "../../storybook/playwright/story"
const profiles = [
{ name: "summaries off no reasoning", summaries: false, reasoning: "none", tool: false, thinking: true, body: false },
{
name: "summaries off reasoning heading",
summaries: false,
reasoning: "heading",
tool: false,
thinking: true,
body: false,
heading: true,
},
{
name: "summaries off with visible tool",
summaries: false,
reasoning: "heading",
tool: true,
thinking: true,
body: false,
heading: true,
},
{ name: "summaries on no content", summaries: true, reasoning: "none", tool: false, thinking: true, body: false },
{
name: "summaries on blank reasoning",
summaries: true,
reasoning: "blank",
tool: false,
thinking: true,
body: false,
},
{
name: "summaries on visible reasoning",
summaries: true,
reasoning: "heading",
tool: false,
thinking: false,
body: true,
},
{
name: "summaries on visible tool no reasoning",
summaries: true,
reasoning: "none",
tool: true,
thinking: false,
body: false,
},
] as const
for (const profile of profiles) {
// Moved from packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts
story(`projects busy reasoning profile ${profile.name}`, async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", {
args: { scenario: "reasoning", summaries: profile.summaries, reasoning: profile.reasoning, tool: profile.tool },
})
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0)
await expect(timeline.locator('[data-timeline-part-id="msg_projection_assistant:reasoning:0"]')).toHaveCount(
profile.body ? 1 : 0,
)
if ("heading" in profile) {
await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeVisible()
}
})
}
// Moved from packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts
story("does not infer reasoning visibility from provider identity", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", {
args: { scenario: "reasoning", reasoning: "none", text: "No reasoning payload" },
})
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(timeline.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0)
await expect(timeline.getByText("No reasoning payload", { exact: true })).toBeVisible()
})
@@ -0,0 +1,65 @@
import { expect, story } from "../../storybook/playwright/story"
story("renders streamed reasoning without starting the app", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--streaming-reasoning-and-text")
await expect(timeline.locator('[data-component="session-timeline"]')).toBeVisible()
await expect(timeline.getByText("Checking the current contract", { exact: true })).toBeVisible()
})
// Moved from packages/app/e2e/regression/session-timeline-context-state.spec.ts
story("preserves a collapsed context group through count and status updates", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "exploration" } })
const group = timeline.locator('[data-timeline-part-ids="tool_context_read,tool_context_glob"]')
const trigger = group.locator('[data-slot="collapsible-trigger"]')
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await timeline.getByRole("button", { name: "Complete read" }).click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await timeline.getByRole("button", { name: "Complete glob" }).click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
})
// Moved from packages/app/e2e/regression/session-timeline-accessibility.spec.ts
story("space activates a focused timeline button instead of scrolling", async ({ mount, page }) => {
await page.emulateMedia({ reducedMotion: "reduce" })
await page.setViewportSize({ width: 800, height: 240 })
const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { scenario: "collapsed" } })
await expect.poll(() => page.evaluate(() => document.documentElement.scrollHeight - innerHeight)).toBeGreaterThan(0)
const trigger = timeline.getByRole("button", { name: "Used Shell", exact: true })
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await trigger.focus()
const before = await page.evaluate(() => window.scrollY)
await trigger.press("Space")
await expect(trigger).toHaveAttribute("aria-expanded", "true")
expect(await page.evaluate(() => window.scrollY)).toBe(before)
})
// Moved from packages/app/e2e/regression/session-timeline-file-projection.spec.ts
story("renders a completed write through the production file component", async ({ mount }) => {
const timeline = await mount("current-session-file-changes--changing-files", { args: { scenario: "write" } })
await expect(
timeline.locator('[data-timeline-part-id="prt_file_projection_write"] [data-component="write-content"]'),
).toBeVisible()
})
// Moved from packages/app/e2e/regression/session-timeline-file-state.spec.ts
story("keeps patch file disclosures independent", async ({ mount }) => {
const timeline = await mount("current-session-file-changes--changing-files", { args: { scenario: "patch" } })
const wrapper = timeline.locator('[data-timeline-part-id="prt_nested_patch"]')
const modified = wrapper.locator('[data-scope="apply-patch"] [data-type="update"] button')
const added = wrapper.locator('[data-scope="apply-patch"] [data-type="add"] button')
const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"] button')
await expect(wrapper.locator('[data-scope="apply-patch"] [aria-expanded="false"]')).toHaveCount(3)
await deleted.click()
await expect(deleted).toHaveAttribute("aria-expanded", "true")
await expect(modified).toHaveAttribute("aria-expanded", "false")
await modified.click()
await expect(modified).toHaveAttribute("aria-expanded", "true")
await deleted.click()
await expect(deleted).toHaveAttribute("aria-expanded", "false")
await expect(modified).toHaveAttribute("aria-expanded", "true")
await expect(added).toHaveAttribute("aria-expanded", "false")
await added.click()
await expect(added).toHaveAttribute("aria-expanded", "true")
await expect(modified).toHaveAttribute("aria-expanded", "true")
await expect(deleted).toHaveAttribute("aria-expanded", "false")
})
@@ -0,0 +1,142 @@
import { expect, story } from "../../storybook/playwright/story"
// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts
story("renders every admitted tool family and hides timeline-only exclusions", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "workflow" } })
const first = timeline.locator(
'[data-timeline-part-ids="tool_family_read,tool_family_glob,tool_family_grep,tool_family_list,tool_family_webfetch,tool_family_websearch,tool_family_subagent,tool_family_shell,tool_family_edit,tool_family_write,tool_family_patch"]',
)
const second = timeline.locator('[data-timeline-part-ids="tool_family_skill,tool_family_custom"]')
await expect(first).toBeVisible()
await expect(second).toBeVisible()
await first.getByRole("button").click()
await second.getByRole("button").click()
for (const id of [
"webfetch",
"websearch",
"subagent",
"shell",
"edit",
"write",
"patch",
"question",
"skill",
"custom",
]) {
await expect(timeline.locator(`[data-timeline-part-id="tool_family_${id}"]`), id).toBeVisible()
}
const patch = timeline.locator('[data-timeline-part-id="tool_family_patch"]')
await expect(patch.getByText("1 file", { exact: true })).toBeVisible()
await expect(patch.getByRole("button", { name: "Patch 1 file", exact: true })).toHaveCount(0)
await expect(patch.getByRole("button")).toHaveCount(1)
await expect(patch.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "false")
await expect(patch.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0)
await expect(patch.locator('[data-slot="message-part-actions"]')).toHaveCount(0)
const edit = timeline.locator('[data-timeline-part-id="tool_family_edit"]')
await expect(edit).toContainText("Edit")
await expect(timeline.locator('[data-timeline-part-id="tool_family_todo"]')).toHaveCount(0)
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("renders every tool error outcome without leaking hidden tools", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "failures" } })
const names = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"]
const group = timeline.locator(`[data-timeline-part-ids="${names.map((name) => `tool_error_${name}`).join(",")}"]`)
await expect(group.locator('[data-component="tag"]')).toHaveText(String(names.length))
await group.getByRole("button").click()
await expect(timeline.locator('[data-kind="tool-error-card"]')).toHaveCount(names.length + 1)
const dismissed = timeline.locator('[data-timeline-part-id="tool_error_question_dismissed"]')
await expect(dismissed.getByText(/dismissed/i)).toBeVisible()
await expect(dismissed).toContainText(/dismissed/i)
await expect(timeline.locator('[data-timeline-part-id="tool_error_todo"]')).toHaveCount(0)
for (const name of names) await expect(timeline.locator(`[data-timeline-part-id="tool_error_${name}"]`)).toBeVisible()
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("transitions shell and question through running error outcomes", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "transition" } })
const shell = timeline.locator('[data-timeline-part-id="tool_transition_shell"]')
const question = timeline.locator('[data-timeline-part-id="tool_transition_question"]')
await expect(shell).toBeVisible()
await expect(question).toHaveCount(0)
await timeline.getByRole("button", { name: "Fail running tools" }).click()
await expect(shell.locator('[data-kind="tool-error-card"]')).toBeVisible()
await expect(shell).toContainText("Command exited 1")
await expect(question).toContainText(/dismissed/i)
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("labels all web search provider variants", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "providers" } })
await timeline.getByRole("button", { name: "Used Parallel Web Search, Exa Web Search, Web Search" }).click()
const tools = timeline.locator('[data-component="context-tool-group-list"]')
await expect(tools.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
await expect(tools.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
await expect(tools.getByRole("button", { name: /^Web Search/ })).toBeVisible()
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("labels completed searches with result counts", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "results" } })
const group = timeline.locator('[data-timeline-part-ids="tool_label_glob,tool_label_grep,tool_label_read"]')
await group.locator('[data-slot="collapsible-trigger"]').click()
const rows = group.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
await expect(rows.filter({ hasText: "Glob" })).toContainText("(1 match)")
await expect(rows.filter({ hasText: "Grep" })).toContainText("(12 matches)")
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("labels read tools from their path input", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "read" } })
const group = timeline.locator('[data-timeline-part-ids="prt_read_path"]')
await group.locator('[data-slot="collapsible-trigger"]').click()
await expect(
group
.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
.filter({ hasText: "Read" }),
).toContainText("a.ts")
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("labels skill tools from IDs and result metadata", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "skills" } })
const group = timeline.locator('[data-timeline-part-ids="tool_skill_id,tool_skill_name"]')
await expect(group.getByRole("button")).toHaveAccessibleName("Used Skill")
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await group.getByRole("button").click()
const loaded = group.locator('[data-component="tool-loaded-item"]')
await expect(loaded).toHaveCount(1)
await expect(loaded).toHaveAttribute("aria-label", "Loaded frontend-design, OpenCode skills")
await expect(loaded).toHaveCSS("line-height", "16px")
await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded")
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skills")
const names = loaded.locator('[data-component="text-shimmer"]')
await expect(names).toHaveCount(2)
await expect(names.nth(0)).toHaveAttribute("aria-label", "frontend-design")
await expect(names.nth(1)).toHaveAttribute("aria-label", "OpenCode")
})
// Moved from packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts
story("groups every collapsed tool until visible text separates the stack", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "steps" } })
await expect(timeline.locator('[data-timeline-part-ids="tool_boundary_read"]')).toBeVisible()
const group = timeline.locator(
'[data-timeline-part-ids="tool_boundary_glob,tool_boundary_grep,tool_boundary_shell,tool_boundary_list"]',
)
await expect(group).toBeVisible()
await expect(group.getByRole("button")).toHaveAccessibleName("Used Glob, Grep, Shell, List")
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
await expect(timeline.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(3)
await expect(timeline.locator('[data-timeline-spacing="content"]')).toHaveCount(2)
await expect(timeline.locator('[data-timeline-spacing="content"]').nth(0)).toHaveCSS("padding-top", "16px")
})
// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts
story("combines adjacent edit calls and repeated files into one group", async ({ mount }) => {
const timeline = await mount("current-session-file-changes--changing-files", { args: { scenario: "repeated" } })
const group = timeline.locator('[data-timeline-part-ids="tool_grouped_edit_first,tool_grouped_edit_second"]')
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit")
await expect(group.getByText("1 file", { exact: true })).toBeVisible()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts"])
await expect(group.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
})
+4 -1
View File
@@ -47,10 +47,13 @@
"scripts": {
"generate:progress-indicator": "bun script/generate-session-progress-indicator.ts",
"typecheck": "tsgo -b",
"test": "bun test src --only-failures"
"test": "bun test src --only-failures",
"test:components": "playwright test --config playwright.components.config.ts",
"test:components:ui": "playwright test --config playwright.components.config.ts --ui"
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@playwright/test": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
@@ -0,0 +1,4 @@
import { fileURLToPath } from "node:url"
import { componentConfig } from "../storybook/playwright/config"
export default componentConfig(fileURLToPath(new URL(".", import.meta.url)))
@@ -1,6 +1,7 @@
import { createStore } from "solid-js/store"
import { CurrentSessionProviders } from "../storybook/current-session-story"
import { editThenTestDocument, reviewDiffs } from "../storybook/current-session-fixtures"
import { SessionReview } from "./session-review"
import { SessionReview, type SessionReviewComment } from "./session-review"
function ReviewStory(props: { split?: boolean }) {
return (
@@ -44,3 +45,35 @@ export const UnifiedDark = {
globals: { theme: "dark" },
render: () => <ReviewStory />,
}
function InteractiveCommentsStory() {
const [state, setState] = createStore({ comments: [] as SessionReviewComment[] })
const file = "src/review.ts"
const diffs = [
{
file,
additions: 1,
deletions: 1,
status: "modified" as const,
patch:
"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",
},
]
return (
<CurrentSessionProviders document={editThenTestDocument}>
<div class="mx-auto h-screen min-h-[620px] w-full max-w-[900px] overflow-hidden bg-background-base">
<SessionReview
title="Changes in this Session"
diffs={diffs}
open={[file]}
comments={state.comments}
onLineComment={(comment) =>
setState("comments", (comments) => [...comments, { id: `comment-${comments.length + 1}`, ...comment }])
}
/>
</div>
</CurrentSessionProviders>
)
}
export const InteractiveComments = { render: () => <InteractiveCommentsStory /> }
@@ -0,0 +1,75 @@
import type { JsonValue, SessionMessageAssistant, SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import type { SessionDocument } from "../document"
import { CURRENT_SESSION_ID, STORY_MODEL, STORY_TIME, thinkingDocument } from "./current-session-fixtures"
export function storyTool(
id: string,
name: string,
status: "streaming" | "running" | "completed" | "error",
input: Record<string, JsonValue>,
options: { metadata?: Record<string, JsonValue>; output?: string; error?: string; raw?: string } = {},
): SessionMessageAssistantTool {
const state =
status === "streaming"
? { status, input: options.raw ?? JSON.stringify(input) }
: status === "running"
? { status, input, metadata: { ...options.metadata, ...(options.output ? { output: options.output } : {}) } }
: status === "error"
? {
status,
input,
error: { type: "ToolExecutionError", message: options.error ?? `${name} failed visibly` },
metadata: options.metadata,
}
: {
status,
input,
content: [{ type: "text" as const, text: options.output ?? "Complete" }] as [
{ type: "text"; text: string },
],
metadata: options.metadata,
}
return {
type: "tool",
id,
name,
state,
time: {
created: STORY_TIME,
...(status === "streaming" ? {} : { ran: STORY_TIME + 100 }),
...(status === "completed" || status === "error" ? { completed: STORY_TIME + 200 } : {}),
},
}
}
export function storyDocument(content: SessionMessageAssistant["content"], busy = false): SessionDocument {
return {
sessionID: CURRENT_SESSION_ID,
messages: [
...thinkingDocument.messages,
{
id: "msg_tool_projection_assistant",
type: "assistant",
agent: "build",
model: STORY_MODEL,
content,
time: { created: STORY_TIME, ...(busy ? {} : { completed: STORY_TIME + 300 }) },
},
],
status: { type: busy ? "busy" : "idle" },
diffs: [],
}
}
export function storyPatchFile(file: string, status: "modified" | "added" = "modified") {
return {
file,
status,
patch:
status === "added"
? "@@ -0,0 +1 @@\n+export const after = true"
: "@@ -1 +1 @@\n-export const before = true\n+export const after = true",
additions: 1,
deletions: status === "added" ? 0 : 1,
}
}
@@ -21,8 +21,19 @@ export function CurrentSessionProviders(props: { document: SessionDocument; chil
{ name: "test", color: "green" },
],
provider: {
all: new Map([["anthropic", { models: { "claude-sonnet-4": { name: "Claude Sonnet 4" } } }]]),
connected: ["anthropic"],
all: new Map<string, { models: Record<string, { name: string }> }>([
["anthropic", { models: { "claude-sonnet-4": { name: "Claude Sonnet 4" } } }],
[
"company-gateway",
{
models: {
"fast-nano": { name: "GPT-5.4 nano" },
"long-context": { name: "Company Gateway Extra Long Context Model for Narrow Timeline Layouts" },
},
},
],
]),
connected: ["anthropic", "company-gateway"],
default: { anthropic: "claude-sonnet-4" },
},
session: [
@@ -1,4 +1,7 @@
import { CurrentSessionTimelineStory } from "../storybook/current-session-story"
import { createTwoFilesPatch } from "diff"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story"
import {
editThenTestDocument,
fileChangeLoadingDocument,
@@ -6,6 +9,7 @@ import {
multiFilePatchDocument,
writeFileDocument,
} from "../storybook/current-session-fixtures"
import { storyDocument, storyPatchFile, storyTool } from "../storybook/current-session-scenarios"
import { SessionTimeline } from "./session-timeline"
export default {
@@ -70,6 +74,137 @@ export const PatchedTwoFiles = {
),
}
const RepeatedEdits = {
render: () => (
<CurrentSessionTimelineStory
title="Repeated edits of one file"
description="Consecutive improvements to the same source file remain together in one expanded change."
document={storyDocument([
storyTool(
"tool_grouped_edit_first",
"edit",
"completed",
{ path: "src/first.ts", oldString: "one", newString: "two" },
{
metadata: { files: [storyPatchFile("src/first.ts")] },
},
),
storyTool(
"tool_grouped_edit_second",
"edit",
"completed",
{ path: "src/first.ts", oldString: "two", newString: "three" },
{
metadata: { files: [storyPatchFile("src/first.ts")] },
},
),
])}
editToolDefaultOpen
/>
),
}
function EditSiblingUpdateStory() {
const [state, setState] = createStore({ sibling: false })
const document = createMemo(() => ({
...editThenTestDocument,
status: { type: "busy" as const },
messages: editThenTestDocument.messages
.filter((message) => message.id === "msg_user_edit" || message.id === "msg_assistant_edit")
.map((message) => {
if (message.type !== "assistant") return message
return {
...message,
time: { created: message.time.created },
content: [
...message.content,
...(state.sibling ? [{ type: "text" as const, text: "Streaming added a later assistant text part." }] : []),
],
}
}),
}))
return (
<section class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6">
<button type="button" onClick={() => setState("sibling", true)}>
Stream sibling content
</button>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} editToolDefaultOpen />
</CurrentSessionProviders>
</section>
)
}
const EditWithStreamedSibling = { render: () => <EditSiblingUpdateStory /> }
const ThreeFilePatch = {
render: () => {
const source = (changed: boolean) =>
Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join("")
const files = [
{ file: "src/a.ts", status: "modified" },
{ file: "src/b.ts", status: "added" },
{ file: "src/old.ts", status: "deleted" },
].map(({ file, status }) => ({
file,
status,
patch: createTwoFilesPatch(
`a/${file}`,
`b/${file}`,
status === "added" ? "" : source(false),
status === "deleted" ? "" : source(true),
),
additions: status === "deleted" ? 0 : 4,
deletions: status === "added" ? 0 : 3,
}))
return (
<CurrentSessionTimelineStory
title="Update, create, and remove files"
description="Each changed file can be opened and closed independently."
document={storyDocument([
storyTool(
"prt_nested_patch",
"patch",
"completed",
{ patchText: "Update three files" },
{ metadata: { files } },
),
])}
editToolDefaultOpen
/>
)
},
}
const WrittenSource = {
render: () => (
<CurrentSessionTimelineStory
title="Create a source file"
description="A completed TypeScript write renders its generated source."
document={storyDocument([
storyTool("prt_file_projection_write", "write", "completed", {
path: "src/write.ts",
content: "export const written = true\n",
}),
])}
editToolDefaultOpen
/>
),
}
const fileScenarios = {
repeated: RepeatedEdits,
streaming: EditWithStreamedSibling,
patch: ThreeFilePatch,
write: WrittenSource,
}
export const ChangingFiles = {
args: { scenario: "streaming" },
argTypes: { scenario: { control: "select", options: Object.keys(fileScenarios) } },
render: (args: { scenario: string }) => fileScenarios[args.scenario as keyof typeof fileScenarios].render(),
}
export const CreatedANewFile = {
render: () => (
<CurrentSessionTimelineStory
@@ -1,10 +1,19 @@
import { CurrentSessionTimelineStory } from "../storybook/current-session-story"
import type { JsonValue, SessionMessageAssistant, SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import type { SessionDocument } from "../document"
import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story"
import {
CURRENT_SESSION_ID,
STORY_MODEL,
STORY_TIME,
inspectAndExplainDocument,
loadedResourcesDocument,
subagentDocument,
thinkingDocument,
webResearchDocument,
} from "../storybook/current-session-fixtures"
import { storyDocument, storyPatchFile, storyTool } from "../storybook/current-session-scenarios"
import { SessionTimeline } from "./session-timeline"
export default {
@@ -65,3 +74,318 @@ export const DelegateFocusedTasks = {
/>
),
}
function CodebaseExplorationStory() {
const [state, setState] = createStore({ read: false, glob: false })
const tool = (name: "read" | "glob", completed: boolean) => {
const input = name === "read" ? { path: "src/a.ts", offset: 0, limit: 120 } : { path: ".", pattern: "**/*.ts" }
return {
type: "tool",
id: `tool_context_${name}`,
name,
state: completed
? { status: "completed", input, content: [{ type: "text", text: "Complete" }], metadata: {} }
: { status: "running", input, metadata: {} },
time: {
created: STORY_TIME,
ran: STORY_TIME + 100,
...(completed ? { completed: STORY_TIME + 200 } : {}),
},
} satisfies SessionMessageAssistantTool
}
const document = createMemo(
() =>
({
sessionID: CURRENT_SESSION_ID,
messages: [
...thinkingDocument.messages,
{
id: "msg_codebase_exploration_assistant",
type: "assistant",
agent: "build",
model: STORY_MODEL,
content: [tool("read", state.read), tool("glob", state.glob)],
time: { created: STORY_TIME, ...(state.read && state.glob ? { completed: STORY_TIME + 300 } : {}) },
} satisfies SessionMessageAssistant,
],
status: { type: state.read && state.glob ? "idle" : "busy" },
diffs: [],
}) satisfies SessionDocument,
)
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<div class="flex gap-2">
<button type="button" onClick={() => setState("read", true)}>
Complete read
</button>
<button type="button" onClick={() => setState("glob", true)}>
Complete glob
</button>
</div>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} />
</CurrentSessionProviders>
</section>
)
}
const ExploreTheCodebase = { render: () => <CodebaseExplorationStory /> }
const CompareSearchProviders = {
render: () => (
<CurrentSessionTimelineStory
title="Compare web search providers"
description="Search results identify Parallel, Exa, and the generic provider clearly."
document={storyDocument([
storyTool(
"tool_search_parallel",
"websearch",
"completed",
{ query: "parallel" },
{ metadata: { provider: "parallel" } },
),
storyTool("tool_search_exa", "websearch", "completed", { query: "exa" }, { metadata: { provider: "exa" } }),
storyTool("tool_search_generic", "websearch", "completed", { query: "generic" }),
])}
/>
),
}
const SearchResultsAndFiles = {
render: () => (
<CurrentSessionTimelineStory
title="Search results and opened files"
description="Grouped file searches show their match counts and the filename that was inspected."
document={storyDocument([
storyTool(
"tool_label_glob",
"glob",
"completed",
{ path: ".", pattern: "**/*.ts" },
{ metadata: { count: 1 } },
),
storyTool(
"tool_label_grep",
"grep",
"completed",
{ path: ".", pattern: "value" },
{ metadata: { matches: 12 } },
),
storyTool("tool_label_read", "read", "completed", { path: "src/a.ts" }),
])}
/>
),
}
const ReadOneFile = {
render: () => (
<CurrentSessionTimelineStory
title="Read one source file"
description="A single read opens its own file label without neighboring tool calls."
document={storyDocument([storyTool("prt_read_path", "read", "completed", { path: "src/a.ts" })])}
/>
),
}
const LoadingSpecializedSkills = {
render: () => (
<CurrentSessionTimelineStory
title="Loading specialized skills"
description="Active and completed skills display their identifier or resolved name."
document={storyDocument([
storyTool("tool_skill_id", "skill", "running", { id: "frontend-design" }),
storyTool("tool_skill_name", "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }),
])}
/>
),
}
const ResearchAcrossSteps = {
render: () => (
<CurrentSessionTimelineStory
title="Research across separate steps"
description="An explanation and command naturally separate independent file-investigation groups."
document={storyDocument([
storyTool("tool_boundary_read", "read", "completed", { path: "src/a.ts" }),
{ type: "text", text: "Boundary text" },
storyTool("tool_boundary_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
storyTool("tool_boundary_grep", "grep", "completed", { path: ".", pattern: "stable" }),
storyTool("tool_boundary_shell", "shell", "completed", { command: "printf done" }, { output: "done" }),
storyTool("tool_boundary_list", "list", "completed", { path: "src" }),
])}
/>
),
}
const questions = { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
const CompleteAgentWorkflow = {
render: () => (
<CurrentSessionTimelineStory
title="Complete agent workflow"
description="A realistic investigation combines file research, web sources, delegation, commands, edits, and specialist guidance."
document={storyDocument([
storyTool("tool_family_read", "read", "completed", { path: "src/a.ts" }),
storyTool("tool_family_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
storyTool("tool_family_grep", "grep", "completed", { path: ".", pattern: "value" }),
storyTool("tool_family_list", "list", "completed", { path: "src" }),
storyTool("tool_family_webfetch", "webfetch", "completed", { url: "https://example.com" }),
storyTool("tool_family_websearch", "websearch", "completed", { query: "timeline stability" }),
storyTool("tool_family_subagent", "subagent", "completed", {
description: "Inspect timeline",
agent: "explore",
prompt: "Inspect the timeline implementation.",
}),
storyTool("tool_family_shell", "shell", "completed", { command: "printf stable" }, { output: "stable" }),
storyTool(
"tool_family_edit",
"edit",
"completed",
{ path: "src/a.ts", oldString: "before", newString: "after" },
{
metadata: { files: [storyPatchFile("src/a.ts")] },
},
),
storyTool("tool_family_write", "write", "completed", {
path: "src/new.ts",
content: "export const stable = true",
}),
storyTool(
"tool_family_patch",
"patch",
"completed",
{ patchText: "Update the projected files" },
{
metadata: { files: [storyPatchFile("src/a.ts")] },
},
),
storyTool("tool_family_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
storyTool("tool_family_question", "question", "completed", questions, { metadata: { answers: [["Yes"]] } }),
storyTool("tool_family_skill", "skill", "completed", { name: "stability" }),
storyTool("tool_family_custom", "custom_mcp_tool", "completed", { target: "timeline" }),
])}
width="860px"
/>
),
}
const RecoverFromToolFailures = {
render: () => {
const names = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"]
const input = (name: string): Record<string, JsonValue> => {
if (name === "shell") return { command: "exit 1" }
if (name === "edit" || name === "write") return { path: "src/error.ts", content: "" }
if (name === "patch") return { patchText: "Update src/error.ts" }
if (name === "webfetch") return { url: "https://example.com" }
if (name === "websearch") return { query: "failure" }
if (name === "subagent") return { description: "Fail subagent", agent: "explore", prompt: "Inspect." }
if (name === "skill") return { name: "failure" }
return { target: "failure" }
}
return (
<CurrentSessionTimelineStory
title="Recover from failed work"
description="Commands, edits, searches, and delegated work explain their failures while dismissed questions stay recognizable."
document={storyDocument([
...names.map((name) => storyTool(`tool_error_${name}`, name, "error", input(name))),
storyTool("tool_error_question_dismissed", "question", "error", questions, {
error: "The user dismissed this question",
}),
storyTool("tool_error_question_transport", "question", "error", questions, {
error: "Question transport failed",
}),
storyTool("tool_error_todo", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
])}
width="860px"
/>
)
},
}
function FailedCommandAndQuestionStory() {
const [state, setState] = createStore({ failed: false })
const document = createMemo(() =>
storyDocument(
[
storyTool(
"tool_transition_shell",
"shell",
state.failed ? "error" : "running",
{ command: "exit 1" },
{
error: "Command exited 1",
},
),
storyTool("tool_transition_question", "question", state.failed ? "error" : "running", questions, {
error: "The user dismissed this question",
}),
],
!state.failed,
),
)
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<button type="button" onClick={() => setState("failed", true)}>
Fail running tools
</button>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} shellToolDefaultOpen />
</CurrentSessionProviders>
</section>
)
}
const FailedCommandAndQuestion = { render: () => <FailedCommandAndQuestionStory /> }
const DelegatingAnAgent = {
render: () => (
<CurrentSessionTimelineStory
title="Delegating an agent"
description="The assistant shows its compact delegation status while preparing a focused task."
document={storyDocument([storyTool("tool_notice_delegation", "subagent", "streaming", {}, { raw: "" })], true)}
/>
),
}
const StartingBackgroundWork = {
render: () => (
<CurrentSessionTimelineStory
title="Starting background work"
description="A delegated task keeps its ordinary label until the background request completes."
document={storyDocument(
[
storyTool(
"tool_notice_background",
"subagent",
"running",
{ description: "Inspect code", background: true },
{
metadata: { status: "running" },
},
),
],
true,
)}
/>
),
}
const researchScenarios = {
workflow: CompleteAgentWorkflow,
exploration: ExploreTheCodebase,
providers: CompareSearchProviders,
results: SearchResultsAndFiles,
read: ReadOneFile,
skills: LoadingSpecializedSkills,
steps: ResearchAcrossSteps,
failures: RecoverFromToolFailures,
transition: FailedCommandAndQuestion,
delegation: DelegatingAnAgent,
background: StartingBackgroundWork,
}
export const AgentResearch = {
args: { scenario: "workflow" },
argTypes: { scenario: { control: "select", options: Object.keys(researchScenarios) } },
render: (args: { scenario: string }) => researchScenarios[args.scenario as keyof typeof researchScenarios].render(),
}
@@ -1,4 +1,7 @@
import { CurrentSessionTimelineStory } from "../storybook/current-session-story"
import type { SessionMessageAssistant } from "@opencode-ai/client/promise"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story"
import {
executeCodeDocument,
expandedShellDocument,
@@ -9,6 +12,7 @@ import {
terminalPassedDocument,
terminalRunningDocument,
} from "../storybook/current-session-fixtures"
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
import { SessionTimeline } from "./session-timeline"
export default {
@@ -62,6 +66,17 @@ export const UserCommandCompleted = {
),
}
const CollapsedShell = {
render: () => (
<CurrentSessionTimelineStory
title="Collapsed completed shell"
description="A focused shell disclosure responds to the keyboard without scrolling its containing surface."
document={terminalPassedDocument}
width="720px"
/>
),
}
export const TestsPassed = {
render: () => (
<CurrentSessionTimelineStory
@@ -110,6 +125,75 @@ export const TestFailed = {
),
}
function InteractiveCommandStory(props: { expanded?: boolean; streaming?: boolean }) {
const [state, setState] = createStore({
phase: props.streaming ? "streaming" : "completed",
lines: 3,
sibling: false,
busy: false,
})
const document = createMemo(() => {
const phase = state.phase as "streaming" | "input" | "running" | "completed"
const command = phase === "streaming" ? "" : "printf ready"
const content: SessionMessageAssistant["content"] = [
storyTool("tool_shell_lifecycle", "shell", phase === "input" ? "streaming" : phase, command ? { command } : {}, {
output:
phase === "running"
? "still running"
: Array.from({ length: state.lines }, (_, index) => `line ${index + 1}`).join("\n"),
...(phase === "streaming" ? { raw: "" } : {}),
}),
...(state.sibling ? [{ type: "text" as const, text: "Sibling content" }] : []),
]
return {
...storyDocument(content, phase !== "completed"),
status: { type: phase !== "completed" || state.busy ? ("busy" as const) : ("idle" as const) },
}
})
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<div class="flex gap-3">
<button type="button" onClick={() => setState("phase", "input")}>
Complete input
</button>
<button type="button" onClick={() => setState("phase", "running")}>
Run command
</button>
<button type="button" onClick={() => setState("phase", "completed")}>
Complete command
</button>
<button type="button" onClick={() => setState("lines", 6)}>
Update output
</button>
<button type="button" onClick={() => setState("sibling", true)}>
Append sibling
</button>
<button type="button" onClick={() => setState("busy", true)}>
Mark session busy
</button>
<button type="button" onClick={() => setState("busy", false)}>
Mark session idle
</button>
</div>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} shellToolDefaultOpen={props.expanded} />
</CurrentSessionProviders>
</section>
)
}
const RunACommand = {
args: { expanded: false, streaming: false },
render: (args: { expanded: boolean; streaming: boolean }) => <InteractiveCommandStory {...args} />,
}
export const TerminalCommands = {
args: { scenario: "command", expanded: false, streaming: false },
argTypes: { scenario: { control: "select", options: ["command", "collapsed"] } },
render: (args: { scenario: string; expanded: boolean; streaming: boolean }) =>
args.scenario === "collapsed" ? CollapsedShell.render() : RunACommand.render(args),
}
export const FixedAndPassed = {
render: () => (
<CurrentSessionTimelineStory
@@ -1,6 +1,13 @@
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import type { SessionDocument } from "../document"
import { SessionTimeline } from "./session-timeline"
import { CurrentSessionTimelineStory } from "../storybook/current-session-story"
import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story"
import {
CURRENT_SESSION_ID,
STORY_MODEL,
STORY_TIME,
attachmentsAndCommentsDocument,
attachmentsAndCommentsPresentation,
compactionCancelledDocument,
@@ -53,6 +60,181 @@ export const StreamingReasoningAndText = {
),
}
function AgentReasoningStory(props: { summaries: boolean; reasoning: string; tool: boolean; text: string }) {
const content = [
...(props.reasoning === "none"
? []
: [
{
type: "reasoning" as const,
text: props.reasoning === "blank" ? " " : "## Inspecting stability",
time: { created: STORY_TIME + 100 },
},
]),
...(props.tool
? [
{
type: "tool" as const,
id: "tool_reasoning_projection_skill",
name: "skill",
state: { status: "running" as const, input: { name: "inspect" }, metadata: {} },
time: { created: STORY_TIME + 200, ran: STORY_TIME + 250 },
},
]
: []),
...(props.text ? [{ type: "text" as const, text: props.text }] : []),
] satisfies SessionMessageAssistant["content"]
const document = {
sessionID: CURRENT_SESSION_ID,
messages: [
...thinkingDocument.messages,
{
id: "msg_projection_assistant",
type: "assistant",
agent: "build",
model: STORY_MODEL,
content,
time: { created: STORY_TIME },
},
],
status: { type: "busy" },
diffs: [],
} satisfies SessionDocument
return (
<section class="mx-auto w-full max-w-[720px] p-6">
<CurrentSessionProviders document={document}>
<SessionTimeline document={document} showReasoningSummaries={props.summaries} />
</CurrentSessionProviders>
</section>
)
}
const AgentReasoning = {
args: { summaries: true, reasoning: "heading", tool: false, text: "" },
argTypes: { reasoning: { control: "select", options: ["none", "blank", "heading"] } },
render: (args: { summaries: boolean; reasoning: string; tool: boolean; text: string }) => (
<AgentReasoningStory {...args} />
),
}
function HiddenReasoningStory() {
const [state, setState] = createStore({ phase: "thinking" })
const document = createMemo(() => {
const finished = state.phase === "idle"
const running = state.phase === "running"
return {
sessionID: CURRENT_SESSION_ID,
messages: [
...thinkingDocument.messages,
{
id: "msg_hidden_reasoning_lifecycle",
type: "assistant",
agent: "build",
model: STORY_MODEL,
content: [
{ type: "reasoning", text: "## Inspecting stability", time: { created: STORY_TIME + 100 } },
...(running || finished
? [
{
type: "tool" as const,
id: "tool_hidden_reasoning_shell",
name: "shell",
state: finished
? {
status: "completed" as const,
input: { command: "printf done" },
content: [{ type: "text" as const, text: "done" }],
metadata: {},
}
: { status: "running" as const, input: { command: "printf done" }, metadata: {} },
time: {
created: STORY_TIME + 200,
ran: STORY_TIME + 250,
...(finished ? { completed: STORY_TIME + 300 } : {}),
},
},
]
: []),
],
time: { created: STORY_TIME, ...(finished ? { completed: STORY_TIME + 400 } : {}) },
},
],
status: { type: finished ? "idle" : "busy" },
diffs: [],
} satisfies SessionDocument
})
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<div class="flex gap-3">
<button type="button" onClick={() => setState("phase", "running")}>
Start shell
</button>
<button type="button" onClick={() => setState("phase", "idle")}>
Finish session
</button>
</div>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} showReasoningSummaries={false} />
</CurrentSessionProviders>
</section>
)
}
const WorkingWithoutReasoningDetails = { render: () => <HiddenReasoningStory /> }
function RetryAndRecoverStory() {
const [state, setState] = createStore({ phase: "thinking" })
const document = createMemo(() => {
const retry = state.phase === "retry"
const finished = state.phase === "idle"
return {
sessionID: CURRENT_SESSION_ID,
messages: [
...thinkingDocument.messages,
{
id: "msg_retry_recovery_lifecycle",
type: "assistant",
agent: "build",
model: STORY_MODEL,
content: finished ? [{ type: "text" as const, text: "Recovered response" }] : [],
...(retry
? {
retry: {
attempt: 2,
at: 1_900_000_000_000,
error: { type: "ProviderRateLimitError", message: "Rate limit reached. Retrying with backoff." },
},
}
: {}),
time: { created: STORY_TIME, ...(finished ? { completed: STORY_TIME + 300 } : {}) },
},
],
status: { type: finished ? "idle" : "busy" },
diffs: [],
} satisfies SessionDocument
})
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<div class="flex gap-3">
<button type="button" onClick={() => setState("phase", "retry")}>
Retry request
</button>
<button type="button" onClick={() => setState("phase", "thinking")}>
Recover request
</button>
<button type="button" onClick={() => setState("phase", "idle")}>
Finish response
</button>
</div>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} />
</CurrentSessionProviders>
</section>
)
}
const RetryAndRecover = { render: () => <RetryAndRecoverStory /> }
export const ProviderRetry = {
render: () => (
<CurrentSessionTimelineStory
@@ -64,6 +246,122 @@ export const ProviderRetry = {
),
}
const noticeUser = { id: "msg_notice_user", type: "user", text: "Run it", time: { created: STORY_TIME } } as const
const noticeAssistant = {
id: "msg_notice_assistant",
type: "assistant",
agent: "build",
model: STORY_MODEL,
content: [{ type: "text", text: "Working" }],
time: { created: STORY_TIME + 1, completed: STORY_TIME + 2 },
} satisfies SessionMessageInfo
const AgentActivityNotices = {
render: () => (
<CurrentSessionTimelineStory
title="Agent activity and Session notices"
description="Agent changes, delegated work, restarted Sessions, and loaded skills appear in their original order."
document={{
sessionID: CURRENT_SESSION_ID,
status: { type: "idle" },
diffs: [],
messages: [
noticeUser,
{ id: "msg_notice_agent", type: "agent-switched", agent: "explore", time: { created: STORY_TIME + 1 } },
noticeAssistant,
{
id: "msg_notice_subagent",
type: "synthetic",
text: "done",
description: "Search code",
metadata: { source: "subagent", agent: "explore", state: "completed" },
time: { created: STORY_TIME + 3 },
},
{
id: "msg_notice_restart",
type: "synthetic",
text: "continue",
description: "Continuing after restart",
time: { created: STORY_TIME + 4 },
},
{
id: "msg_notice_skill",
type: "skill",
skill: "review",
name: "Review",
text: "instructions",
time: { created: STORY_TIME + 5 },
},
],
}}
width="720px"
/>
),
}
function CompactSessionStory() {
const [state, setState] = createStore({ phase: "running", summary: "", second: false })
const document = createMemo(() => {
const failed = state.phase === "failed"
const completed = state.phase === "completed"
const message = {
id: "msg_notice_compaction",
type: "compaction" as const,
status: failed ? ("failed" as const) : completed ? ("completed" as const) : ("running" as const),
reason: "auto" as const,
...(failed
? {
error: {
type: "compaction.failed",
message: 'Error: {"error":{"type":"ProviderError","message":"The provider rejected the summary."}}',
},
}
: { summary: state.summary, recent: "" }),
time: { created: STORY_TIME + 10 },
}
const cancelled = {
id: "msg_notice_compaction_cancelled",
type: "compaction" as const,
status: "failed" as const,
reason: "manual" as const,
error: { type: "aborted", message: "Cancellation detail should stay hidden." },
time: { created: STORY_TIME + 20 },
}
return {
sessionID: CURRENT_SESSION_ID,
messages: [noticeUser, noticeAssistant, message, ...(state.second ? [cancelled] : [])],
status: { type: completed || failed ? "idle" : "busy" },
diffs: [],
} satisfies SessionDocument
})
return (
<section class="mx-auto flex w-full max-w-[760px] flex-col gap-4 p-6">
<div class="flex flex-wrap gap-3">
<button type="button" onClick={() => setState("summary", "## Checkpoint\n\nStreamed implementation details.")}>
Stream summary
</button>
<button
type="button"
onClick={() => setState({ phase: "completed", summary: "## Checkpoint\n\nFinal implementation details." })}
>
Complete summary
</button>
<button type="button" onClick={() => setState("phase", "failed")}>
Fail compaction
</button>
<button type="button" onClick={() => setState("second", true)}>
Cancel next compaction
</button>
</div>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} />
</CurrentSessionProviders>
</section>
)
}
const CompactSession = { render: () => <CompactSessionStory /> }
export const CompactionInProgress = {
render: () => (
<CurrentSessionTimelineStory
@@ -156,6 +454,155 @@ export const MixedDirectionRtl = {
),
}
const MovedLocation = {
render: () => (
<CurrentSessionTimelineStory
title="Moved Session location"
description="A changed working directory stays compact, truncates, and exposes its tooltip."
document={{
...thinkingDocument,
status: { type: "idle" },
messages: [
...thinkingDocument.messages,
{
id: "msg_story_location",
type: "location-switched",
location: { directory: `/Users/usrnk1/Developer/opencode/${"nested-directory/".repeat(24)}session` },
time: { created: 1_735_689_633_000 },
},
],
}}
width="480px"
/>
),
}
const InterruptedTurn = {
render: () => (
<CurrentSessionTimelineStory
title="Interrupted assistant turn"
description="The interruption divider stays between the original response and its continuation."
document={{
...thinkingDocument,
status: { type: "idle" },
messages: [
...thinkingDocument.messages,
{
id: "msg_story_interrupted_before",
type: "assistant",
agent: "build",
model: { id: "claude-sonnet-4", providerID: "anthropic" },
content: [{ type: "text", text: "Before" }],
error: { type: "MessageAbortedError", message: "Stopped" },
time: { created: 1_735_689_633_000, completed: 1_735_689_634_000 },
},
{
id: "msg_story_interrupted_after",
type: "assistant",
agent: "build",
model: { id: "claude-sonnet-4", providerID: "anthropic" },
content: [{ type: "text", text: "After" }],
time: { created: 1_735_689_635_000, completed: 1_735_689_636_000 },
},
],
}}
width="560px"
/>
),
}
const AliasedModelNotices = {
render: () => (
<CurrentSessionTimelineStory
title="Aliased model notices"
description="Provider display names, model variants, and long-name truncation use the production notice component."
document={{
...thinkingDocument,
status: { type: "idle" },
messages: [
{
id: "msg_story_fast_nano",
type: "model-switched",
model: { providerID: "company-gateway", id: "fast-nano", variant: "xhigh" },
time: { created: 1_735_689_590_000 },
},
{
id: "msg_story_long_context",
type: "model-switched",
model: { providerID: "company-gateway", id: "long-context" },
time: { created: 1_735_689_591_000 },
},
...thinkingDocument.messages,
],
}}
width="420px"
/>
),
}
const RichUserAttachments = {
render: () => (
<CurrentSessionTimelineStory
title="Prompt with rich attachments"
description="An image, JSON attachment, source-file reference, and agent mention remain individually visible."
document={{
...thinkingDocument,
status: { type: "idle" },
messages: [
{
id: "msg_story_rich_user",
type: "user",
text: "Use @explore with @src/a.ts and inspect the attachments",
agents: [{ name: "explore", mention: { text: "@explore", start: 4, end: 12 } }],
files: [
{
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
mime: "image/png",
name: "pixel.png",
source: { type: "inline" },
},
{ data: "e30=", mime: "application/json", name: "tsconfig.json", source: { type: "inline" } },
{
data: "",
mime: "text/plain",
name: "a.ts",
source: { type: "uri", uri: "src/a.ts" },
mention: { text: "@src/a.ts", start: 18, end: 27 },
},
],
time: { created: 1_735_689_633_000 },
},
],
}}
width="620px"
/>
),
}
const conversationScenarios = {
reasoning: AgentReasoning,
hidden: WorkingWithoutReasoningDetails,
retry: RetryAndRecover,
notices: AgentActivityNotices,
compaction: CompactSession,
location: MovedLocation,
interruption: InterruptedTurn,
models: AliasedModelNotices,
attachments: RichUserAttachments,
}
export const Conversation = {
args: { scenario: "notices", summaries: true, reasoning: "heading", tool: false, text: "" },
argTypes: {
scenario: { control: "select", options: Object.keys(conversationScenarios) },
reasoning: { control: "select", options: ["none", "blank", "heading"] },
},
render: (args: { scenario: string; summaries: boolean; reasoning: string; tool: boolean; text: string }) => {
if (args.scenario === "reasoning") return <AgentReasoningStory {...args} />
return conversationScenarios[args.scenario as Exclude<keyof typeof conversationScenarios, "reasoning">].render()
},
}
export const InstructionsUpdatedSingle = {
render: () => (
<CurrentSessionTimelineStory
+4
View File
@@ -31,6 +31,10 @@ export default defineMain({
async viteFinal(config) {
const { mergeConfig, searchForWorkspaceRoot } = await import("vite")
const merged = mergeConfig(config, {
// Concurrent package suites must not overwrite each other's optimized dependencies.
cacheDir: process.env.PLAYWRIGHT_STORYBOOK_PORT
? path.resolve(here, "../node_modules/.cache/playwright", process.env.PLAYWRIGHT_STORYBOOK_PORT)
: config.cacheDir,
plugins: [tailwindcss(), playgroundCss()],
resolve: {
dedupe: ["solid-js", "solid-js/web", "@solidjs/meta"],
+6 -1
View File
@@ -69,7 +69,7 @@ const frame = createJSXDecorator((Story, context) => {
<MetaProvider>
<Font />
<ThemeProvider>
<LanguageProvider locale="en">
<LanguageProvider locale={typeof context.globals?.locale === "string" ? context.globals.locale : "en"}>
<UiI18nBridge>
<Scheme value={scheme} />
<Direction value={context.globals?.direction} />
@@ -109,6 +109,11 @@ export default definePreview({
description: "Interface direction",
defaultValue: "ltr",
},
locale: {
name: "Locale",
description: "Interface language",
defaultValue: "en",
},
},
parameters: {
actions: {
+1
View File
@@ -12,6 +12,7 @@
"@tailwindcss/vite": "catalog:",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@playwright/test": "catalog:",
"@solidjs/meta": "catalog:",
"@storybook/addon-a11y": "10.4.4",
"@storybook/addon-docs": "10.4.4",
+80
View File
@@ -0,0 +1,80 @@
# Component browser tests
Production Solid components are tested through their existing Storybook stories without booting the app, configuring a server, seeding browser storage, or navigating unrelated routes.
Keep each spec in the package that owns its production component:
- `packages/session-ui/component-tests/` owns timeline, tool, notice, reasoning, lifecycle, and review coverage.
- `packages/app/component-tests/` owns Composer and other app-only component coverage.
- `packages/storybook/playwright/` owns the shared Storybook startup configuration and `story` mount fixture.
Run a package's isolated browser suite from that package:
```sh
# Session UI components.
cd packages/session-ui
bun run test:components
bun run test:components -- component-tests/session-timeline.spec.ts
bun run test:components:ui
# App-owned components.
cd packages/app
bun run test:components
bun run test:components -- component-tests/composer.spec.ts
```
Both suites are separately filterable Turbo tasks:
```sh
bun turbo test:components --filter=@opencode-ai/session-ui
bun turbo test:components --filter=@opencode-ai/app
```
Component browser coverage remains separate from each package's default `test` script and from `packages/app`'s `test:e2e`. Session UI uses port 6006 and app uses 6007, with a separate Vite dependency cache per port, so concurrent Turbo tasks do not compete for a server or overwrite each other's optimized modules. Set `PLAYWRIGHT_STORYBOOK_URL` to reuse an existing Storybook instance locally or `PLAYWRIGHT_STORYBOOK_PORT` to override a package's port; do not give concurrent server-owning tasks the same override.
## CI selection
The `test` workflow selects the suites independently using Turbo's affected workspace graph:
- App-only implementation, story, or component-test changes run app components and full-app E2E, not session-ui components.
- Session-ui changes run session-ui components and dependent app suites. Shared UI, client, util, and other workspace dependencies flow through the same graph.
- Affected Storybook harness code, config, mocks, fixtures, or stories run both component suites, without turning on full-app E2E solely for a harness change.
- The shared preview also imports app CSS and localization outside the package graph. App CSS, public assets, localization, and the bounded persistence/platform/server-scope/path-key dependency chain select both component suites. `script/github/browser-suites.test.ts` checks the preview's transitive runtime imports so this exception cannot silently become stale. The check conservatively follows the real platform module even where Storybook mocks it.
- Lockfiles, package manifests, TypeScript/Turbo configuration, root tooling, unknown non-documentation paths, manual dispatch, and Git/Turbo failures select all browser suites. Documentation-only changes outside affected browser workspaces skip them.
PR comparisons use the tested merge ref's first parent; pushes use the event's previous SHA. Both are normalized to a merge base, with deleted files and both sides of renames included. Each component suite runs on its own Linux runner. Existing Linux/Windows E2E check names and the `v2` ref exclusion are unchanged; selection does not override that exclusion, including on manual dispatch.
Run the selection matrix (real Git refs and Turbo, no browser or workspace install required):
```sh
cd script/github
bun test --root . browser-suites.test.ts
```
## Adding a test
Keep inspectable scenarios next to the production component in a `*.stories.tsx` file. A story owns its fixtures, providers, state, and callbacks; its package-local spec owns user-visible interactions and assertions.
```ts
import { expect, story } from "../../storybook/playwright/story"
// Moved from packages/app/e2e/regression/session-timeline-context-state.spec.ts
story("preserves collapsed state while a tool completes", async ({ mount }) => {
const component = await mount("current-session-research-agents--agent-research", {
args: { scenario: "exploration" },
})
const trigger = component.locator('[data-slot="collapsible-trigger"]')
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await component.getByRole("button", { name: "Complete read" }).click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
})
```
The story ID is the Storybook component ID followed by `--` and the kebab-cased story export. Open the same story in Storybook to inspect exactly the scenario covered by the browser test. Preserve an original-source-path comment for every migrated E2E case.
Keep cross-route navigation, remote-server ownership, persistent session state, full-app virtualization, and workflows spanning independent surfaces in `packages/app/e2e/`.
Component rendering and integration coverage can be complementary. A local story control that installs a completed message does not test event delivery, production reducer cleanup, or a live stream. Keep those original checks in E2E, including stream/chunk identity, compaction and retry events, independent lifecycle transitions, and the real app scroll owner. A provenance comment records the source of a component assertion; it is not evidence that its integration counterpart can be deleted.
When moving an assertion, preserve its discriminating fixture: file status kinds, empty/single-variant inputs, singleton groups, live message state, and the order of intermediate updates. Verify the actual scroll container overflows before asserting that keyboard activation does not scroll it.
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig, devices } from "@playwright/test"
export function componentConfig(directory: string, defaultPort = 6006) {
const port = Number(process.env.PLAYWRIGHT_STORYBOOK_PORT ?? defaultPort)
const baseURL = process.env.PLAYWRIGHT_STORYBOOK_URL ?? `http://127.0.0.1:${port}`
return defineConfig({
testDir: `${directory}/component-tests`,
outputDir: `${directory}/component-tests/test-results`,
timeout: 60_000,
expect: { timeout: 10_000 },
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: [["html", { outputFolder: `${directory}/component-tests/playwright-report`, open: "never" }], ["line"]],
webServer: {
command: `bun --bun run --cwd ${directory}/../storybook storybook -- --port ${port} --ci --no-open`,
url: baseURL,
env: { PLAYWRIGHT_STORYBOOK_PORT: String(port) },
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
use: {
baseURL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [{ name: "components", use: { ...devices["Desktop Chrome"] } }],
})
}
+37
View File
@@ -0,0 +1,37 @@
import { expect, test } from "@playwright/test"
import type { Locator } from "@playwright/test"
export { expect }
export const story = test.extend<{
mount: (
id: string,
options?: { args?: Record<string, string | boolean>; globals?: Record<string, string> },
) => Promise<Locator>
}>({
mount: async ({ page }, use) => {
await use(async (id, options) => {
const query = new URLSearchParams({ id, viewMode: "story" })
if (options?.args) {
query.set(
"args",
Object.entries(options.args)
.map(([key, value]) => `${key}:${value}`)
.join(";"),
)
}
if (options?.globals) {
query.set(
"globals",
Object.entries(options.globals)
.map(([key, value]) => `${key}:${value}`)
.join(";"),
)
}
await page.goto(`/iframe.html?${query}`)
const root = page.locator("#storybook-root")
await expect(root).toBeVisible({ timeout: 30_000 })
return root
})
},
})
+1 -1
View File
@@ -12,5 +12,5 @@
"strict": true,
"types": ["vite/client", "node"]
},
"include": [".storybook/**/*.ts", ".storybook/**/*.tsx"]
"include": [".storybook/**/*.ts", ".storybook/**/*.tsx", "playwright/**/*.ts"]
}
+162
View File
@@ -0,0 +1,162 @@
import { afterAll, expect, test } from "bun:test"
import { mkdtempSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import path from "node:path"
import { browserSuites, sharedAppFile } from "./browser-suites"
const root = path.resolve(import.meta.dir, "../..")
const cwd = mkdtempSync(path.join(tmpdir(), "browser-suites-"))
const all = { app: true, appComponents: true, sessionComponents: true }
const app = { app: true, appComponents: true, sessionComponents: false }
const components = { app: false, appComponents: true, sessionComponents: true }
const none = { app: false, appComponents: false, sessionComponents: false }
function git(...args: string[]) {
const result = Bun.spawnSync(["git", ...args], { cwd })
if (result.exitCode) throw new Error(result.stderr.toString())
return result.stdout.toString().trim()
}
git("init", "-q")
git("config", "user.name", "fixture")
git("config", "user.email", "fixture@example.com")
const files = Bun.spawnSync(["git", "ls-files", "-z"], { cwd: root }).stdout.toString().split("\0")
for (const file of files.filter(
(file) => file.endsWith("/package.json") || ["package.json", "bun.lock", "turbo.json"].includes(file),
)) {
await Bun.write(path.join(cwd, file), Bun.file(path.join(root, file)))
}
await Bun.write(path.join(cwd, "packages/app/src/runtime/i18n/deleted.ts"), "export const old = true")
await Bun.write(path.join(cwd, "packages/app/src/runtime/i18n/renamed.ts"), "export const renamed = true")
git("add", ".")
git("commit", "-qm", "base")
afterAll(() => rmSync(cwd, { recursive: true, force: true }))
test.each([
["packages/app/component-tests/new.spec.ts", app],
["packages/app/src/composer/new.tsx", app],
["packages/app/src/shell/new.tsx", app],
["packages/app/src/composer/new.stories.tsx", app],
["packages/session-ui/src/timeline/new.tsx", all],
["packages/session-ui/component-tests/new.spec.ts", all],
["packages/ui/src/new.tsx", all],
["packages/client/src/new.ts", all],
["packages/util/src/new.ts", all],
["packages/storybook/.storybook/main.ts", components],
["packages/storybook/.storybook/preview.tsx", components],
["packages/storybook/playwright/story.ts", components],
["packages/storybook/playwright/config.ts", components],
["packages/storybook/new.stories.tsx", components],
["packages/app/src/index.css", all],
["packages/app/public/assets/new.woff2", all],
["packages/app/src/runtime/i18n/en.ts", all],
["packages/app/src/runtime/i18n/fr.ts", all],
["packages/app/src/runtime/persistence/storage.ts", all],
["packages/app/src/runtime/server/scope.ts", all],
["packages/app/src/workspaces/path-key.ts", all],
["bun.lock", all],
["package.json", all],
["turbo.json", all],
["packages/app/package.json", all],
[".github/actions/setup-bun/action.yml", all],
["unknown-config", all],
["packages/unknown/shared.ts", all],
["packages/app/tsconfig.json", all],
["patches/shared.patch", all],
["README.md", none],
["docs/guide.md", none],
["packages/www/content/docs/guide.mdx", none],
] as const)(
"selects suites for %s with the real workspace graph",
async (file, expected) => {
const base = git("rev-parse", "HEAD")
const target = Bun.file(path.join(cwd, file))
// Keep manifests valid; changing whitespace still participates in the Git diff.
await Bun.write(target, ((await target.exists()) ? await target.text() : "") + "\n")
git("add", ".")
git("commit", "-qm", file)
expect(browserSuites({ cwd, event: "pull_request", base, head: "HEAD" })).toEqual(expected)
},
30_000,
)
test("deletions participate", () => {
const base = git("rev-parse", "HEAD")
git("rm", "packages/app/src/runtime/i18n/deleted.ts")
git("commit", "-qm", "remove shared input")
expect(browserSuites({ cwd, event: "push", base, head: "HEAD" })).toEqual(all)
}, 30_000)
test("both sides of renames participate", () => {
const base = git("rev-parse", "HEAD")
git("mv", "packages/app/src/runtime/i18n/renamed.ts", "packages/app/src/renamed.ts")
git("commit", "-qm", "rename shared input")
expect(browserSuites({ cwd, event: "push", base, head: "HEAD" })).toEqual(all)
}, 30_000)
test("manual dispatch and unavailable diffs fail safe", () => {
expect(browserSuites({ cwd, event: "workflow_dispatch" })).toEqual(all)
expect(browserSuites({ cwd, event: "push", base: "0".repeat(40), head: "HEAD" })).toEqual(all)
expect(browserSuites({ cwd, event: "pull_request", base: "missing", head: "HEAD" })).toEqual(all)
expect(browserSuites({ cwd, event: "push" })).toEqual(all)
expect(browserSuites({ cwd, base: "HEAD", head: "HEAD" })).toEqual(none)
})
test("uses the merge base, not the tip of a diverged comparison branch", async () => {
const base = git("rev-parse", "HEAD")
await Bun.write(path.join(cwd, "packages/app/src/branch.ts"), "// app branch")
git("add", ".")
git("commit", "-qm", "app branch")
const head = git("rev-parse", "HEAD")
git("switch", "--detach", base)
await Bun.write(path.join(cwd, "packages/session-ui/src/other.ts"), "// base branch only")
git("add", ".")
git("commit", "-qm", "diverged base")
const other = git("rev-parse", "HEAD")
git("switch", "--detach", head)
expect(browserSuites({ cwd, event: "pull_request", base: other, head })).toEqual(app)
expect(browserSuites({ cwd, event: "push", base, head })).toEqual(app)
const merge = git("commit-tree", `${head}^{tree}`, "-p", base, "-p", head, "-m", "PR merge ref")
expect(browserSuites({ cwd, event: "pull_request", base: `${merge}^1`, head: merge })).toEqual(app)
}, 30_000)
test("Turbo failures run everything instead of silently skipping suites", async () => {
const file = Bun.file(path.join(cwd, "turbo.json"))
const original = await file.text()
try {
await Bun.write(file, "not json")
expect(browserSuites({ cwd, event: "push", base: "HEAD^", head: "HEAD" })).toEqual(all)
} finally {
await Bun.write(file, original)
}
}, 30_000)
test("shared app exceptions cover the preview's transitive runtime imports", async () => {
const seen = new Set<string>()
const pending = ["packages/storybook/.storybook/preview.tsx"]
while (pending.length) {
const file = pending.pop()!
if (seen.has(file)) continue
seen.add(file)
if (file.startsWith("packages/app/")) expect(sharedAppFile(file), file).toBe(true)
const source = await Bun.file(path.join(root, file)).text()
const imports = file.endsWith(".css")
? Array.from(source.matchAll(/@import\s+["']([^"']+)["']/g), (match) => match[1]!)
: new Bun.Transpiler({ loader: "tsx" }).scanImports(source).map((item) => item.path)
for (const name of imports) {
if (name.startsWith("@opencode-ai/")) {
// These packages and their dependents are covered by the Turbo graph.
expect(["ui", "session-ui", "client", "util"]).toContain(name.split("/")[1]!)
continue
}
if (!name.startsWith(".") && !name.startsWith("@/")) continue
// Follow the real platform too: conservative, without duplicating Vite's mock aliases.
const target = name.startsWith("@/")
? path.join(root, "packages/app/src", name.slice(2))
: path.resolve(root, path.dirname(file), name)
pending.push(path.relative(root, Bun.resolveSync(target, root)))
}
}
expect(seen.has("packages/app/src/runtime/persistence/storage.ts")).toBe(true)
expect(seen.has("packages/app/src/workspaces/path-key.ts")).toBe(true)
})
+91
View File
@@ -0,0 +1,91 @@
import { appendFileSync } from "node:fs"
const all = { app: true, appComponents: true, sessionComponents: true }
// The preview imports app CSS and LanguageProvider outside the workspace graph.
// The import-closure test below guards this bounded exception against drift.
export function sharedAppFile(file: string) {
return (
file.startsWith("packages/app/public/") ||
(file.startsWith("packages/app/") && file.endsWith(".css")) ||
file.startsWith("packages/app/src/runtime/i18n/") ||
[
"packages/app/package.json",
"packages/app/tsconfig.json",
"packages/app/src/runtime/persistence/storage.ts",
"packages/app/src/runtime/platform/platform.tsx",
"packages/app/src/runtime/server/registry.tsx",
"packages/app/src/runtime/server/scope.ts",
"packages/app/src/workspaces/path-key.ts",
].includes(file)
)
}
export function browserSuites(input: { event?: string; base?: string; head?: string; cwd: string }) {
if (input.event === "workflow_dispatch") return all
try {
if (!input.base || !input.head) throw new Error("missing comparison refs")
const run = (cmd: string[], env = {}) => {
const result = Bun.spawnSync(cmd, { cwd: input.cwd, env: { ...process.env, ...env }, stderr: "pipe" })
if (result.exitCode !== 0) throw new Error(`${cmd[0]} failed: ${result.stderr.toString()}`)
return result.stdout.toString()
}
const base = run(["git", "merge-base", input.base, input.head]).trim()
// Disable rename detection so both old and new paths participate, including deletions.
const files = run(["git", "diff", "--name-only", "--no-renames", "-z", base, input.head, "--"])
.split("\0")
.filter(Boolean)
if (!files.length) return { app: false, appComponents: false, sessionComponents: false }
// Root config, lockfiles, CI tooling and unknown paths are global. Only known
// documentation outside workspaces can bypass Turbo's root-package invalidation.
const docs = (file: string) => /^(?:README(?:\.[^/]+)?\.md|AGENTS\.md|LICENSE|docs\/.*\.mdx?)$/.test(file)
if (files.every(docs)) return { app: false, appComponents: false, sessionComponents: false }
if (files.some((file) => !file.startsWith("packages/") && !docs(file))) return all
if (files.some((file) => /(?:^|\/)(?:bun\.lockb?|package\.json|turbo\.json|tsconfig[^/]*\.json)$/.test(file))) {
return all
}
const packages = run(["git", "ls-tree", "-r", "--name-only", "-z", input.head, "--", "packages"])
.split("\0")
.filter((file) => file.endsWith("/package.json"))
.map((file) => file.slice(0, -"package.json".length))
if (files.some((file) => !docs(file) && !packages.some((directory) => file.startsWith(directory)))) return all
const result = JSON.parse(
run(["bun", "x", "turbo@2.10.2", "ls", "--affected", "--output=json"], {
TURBO_SCM_BASE: base,
TURBO_SCM_HEAD: input.head,
}),
)
if (
!Array.isArray(result.packages?.items) ||
result.packages.items.some((item: { name?: unknown }) => typeof item.name !== "string")
) {
throw new Error("unexpected Turbo package output")
}
const names = new Set(result.packages.items.map((item: { name: string }) => item.name))
const app = names.has("@opencode-ai/app")
const shared = names.has("@opencode-ai/storybook") || files.some(sharedAppFile)
return {
app,
appComponents: app || shared,
sessionComponents: names.has("@opencode-ai/session-ui") || shared,
}
} catch (error) {
console.warn("Unable to select browser suites; running all suites.", error)
return all
}
}
if (import.meta.main) {
const result = browserSuites({
event: process.env.GITHUB_EVENT_NAME,
base: process.env.TURBO_SCM_BASE,
head: process.env.TURBO_SCM_HEAD,
cwd: process.cwd(),
})
const output =
Object.entries(result)
.map(([key, value]) => `${key}=${value}`)
.join("\n") + "\n"
console.log(output.trim())
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, output)
}
+4
View File
@@ -11,6 +11,10 @@
"globalPassThroughEnv": ["CI", "OPENCODE_DISABLE_SHARE"],
"tasks": {
"typecheck": {},
"test:components": {
"outputs": [],
"cache": false
},
"@opencode-ai/enterprise#typecheck": {
"dependsOn": ["@opencode-ai/core#typecheck"]
},