mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-21 20:50:09 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77fdb42d8c | |||
| 08e60630fe | |||
| 7a2adc3a08 | |||
| 7d79b9c70c | |||
| 7800ed86dd | |||
| 8228fca696 | |||
| 4308b6ba26 | |||
| 034fb71c44 | |||
| e60ebb5645 | |||
| 409bf6ea13 | |||
| 4b627a740d |
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"rootDir": "..",
|
||||
"types": ["node", "bun"]
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.tsx", "../src/types.ts"]
|
||||
"include": ["./**/*.ts", "./**/*.tsx", "../component-tests/**/*.ts", "../src/types.ts"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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"] } }],
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -12,5 +12,5 @@
|
||||
"strict": true,
|
||||
"types": ["vite/client", "node"]
|
||||
},
|
||||
"include": [".storybook/**/*.ts", ".storybook/**/*.tsx"]
|
||||
"include": [".storybook/**/*.ts", ".storybook/**/*.tsx", "playwright/**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user