From eb22768a2388ac121359948ea84cc073fc836ba3 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:03:51 +1000 Subject: [PATCH] refactor(app): remove V1 client compatibility (#43045) --- .../timeline-stability/adverse.spec.ts | 25 +- .../timeline-stability/context-matrix.spec.ts | 192 ----- .../environment-matrix.spec.ts | 23 +- .../timeline-stability/file-matrix.spec.ts | 13 +- .../timeline-stability/file-mutation.spec.ts | 20 +- .../timeline-stability/fixture.test.ts | 27 +- .../performance/timeline-stability/fixture.ts | 712 ++++++++++++------ .../timeline-stability/interaction.spec.ts | 109 +-- .../timeline-stability/lifecycle.spec.ts | 32 +- .../timeline-stability/oracle-browser.spec.ts | 10 +- .../scroll-interaction.spec.ts | 41 +- .../timeline-stability/shell-matrix.spec.ts | 54 +- .../timeline-stability/tool-mutation.spec.ts | 55 +- .../timeline-stability/tools.spec.ts | 15 +- .../transition-matrix.spec.ts | 114 +-- ...session-parent-hydration-benchmark.spec.ts | 77 +- .../timeline/session-tab-flash.spec.ts | 4 +- .../session-tab-switch-benchmark.spec.ts | 4 +- .../session-timeline-benchmark.fixture.ts | 234 +++--- .../session-timeline-stress.fixture.ts | 143 ++-- .../e2e/performance/unit/mock-server.test.ts | 117 +-- .../performance/unit/visual-stability.test.ts | 15 +- .../regression/cross-server-tab-close.spec.ts | 58 +- .../prompt-input-v2-command-draft.spec.ts | 3 +- .../regression/prompt-thinking-level.spec.ts | 3 +- .../remote-session-settings.spec.ts | 81 +- .../e2e/regression/remote-tab-busy.spec.ts | 46 +- .../e2e/regression/review-image-flash.spec.ts | 25 +- .../regression/review-line-comment.spec.ts | 26 +- .../e2e/regression/review-open-file.spec.ts | 1 - .../review-state-persistence.spec.ts | 1 - .../review-terminal-stacked.spec.ts | 5 +- .../session-list-path-loading.spec.ts | 3 +- .../regression/session-request-docks.spec.ts | 62 +- .../session-timeline-collapse-state.spec.ts | 221 +++--- .../session-timeline-context-resize.spec.ts | 261 ++++--- .../session-timeline-file-state.spec.ts | 46 +- .../session-timeline-history-root.spec.ts | 31 +- .../session-timeline-lifecycle-state.spec.ts | 9 +- .../session-timeline-notices.spec.ts | 12 +- .../session-timeline-projection.spec.ts | 60 +- ...ession-timeline-reducer-projection.spec.ts | 7 +- .../session-timeline-tool-state.spec.ts | 77 -- .../session-timeline-transport.spec.ts | 46 +- .../session-todo-dock-navigation.spec.ts | 168 ----- .../subagent-child-navigation.spec.ts | 75 +- .../regression/tab-navigate-mousedown.spec.ts | 47 +- .../terminal-composer-focus.spec.ts | 22 +- .../e2e/regression/terminal-hidden.spec.ts | 3 +- .../app/e2e/smoke/session-timeline.fixture.ts | 146 ++-- .../app/e2e/smoke/session-timeline.spec.ts | 25 +- .../user-story/model-selection-flow.spec.ts | 1 - packages/app/e2e/utils/errors.ts | 3 + packages/app/e2e/utils/mock-server.ts | 466 +++--------- packages/app/e2e/utils/sse-transport.ts | 41 +- packages/app/e2e/utils/visual-stability.ts | 17 +- .../e2e/utils/visual-stability/invariant.ts | 6 +- packages/app/public/oc-theme-preload.js | 7 - packages/app/src/app.tsx | 52 +- packages/app/src/components/dialog-fork.tsx | 5 +- .../components/prompt-input/history-store.ts | 19 +- .../components/prompt-input/history.test.ts | 13 +- .../src/components/prompt-input/history.ts | 8 +- .../components/prompt-input/submit.test.ts | 22 +- .../app/src/components/prompt-input/submit.ts | 11 +- .../components/settings-v2/settings-v2.css | 9 - .../src/components/settings-v2/workspaces.tsx | 7 +- packages/app/src/components/titlebar.tsx | 2 +- .../app/src/components/windows-app-menu.tsx | 43 +- packages/app/src/context/comments.tsx | 4 +- packages/app/src/context/file/view-cache.ts | 4 +- .../src/context/global-sync/child-store.ts | 6 +- packages/app/src/context/language.tsx | 2 +- packages/app/src/context/layout.tsx | 34 +- packages/app/src/context/local.tsx | 2 +- packages/app/src/context/models.tsx | 2 +- packages/app/src/context/notification.tsx | 2 +- .../context/permission-auto-respond.test.ts | 23 +- .../src/context/permission-auto-respond.ts | 23 +- packages/app/src/context/permission.tsx | 21 +- packages/app/src/context/platform.tsx | 15 +- packages/app/src/context/prompt-state.ts | 10 +- .../context/server-session-v2-reducer.test.ts | 6 +- .../app/src/context/server-session.test.ts | 295 +------- packages/app/src/context/servers.tsx | 3 +- packages/app/src/context/tab-migration.ts | 6 +- packages/app/src/context/tabs.test.ts | 10 +- packages/app/src/context/tabs.tsx | 2 +- packages/app/src/context/terminal.test.ts | 36 +- packages/app/src/context/terminal.tsx | 57 +- packages/app/src/entry.tsx | 1 - packages/app/src/index.ts | 2 +- packages/app/src/pages/directory-layout.tsx | 78 +- .../pages/home/home-projects-controller.tsx | 2 +- packages/app/src/pages/session.tsx | 10 +- .../src/pages/session/timeline/controller.tsx | 14 +- .../session/timeline/summary-diffs.test.ts | 2 +- packages/app/src/theme-preload.test.ts | 15 - packages/app/src/utils/persist.test.ts | 39 +- packages/app/src/utils/persist.ts | 180 ++--- packages/app/src/utils/server-scope.test.ts | 23 +- packages/app/src/utils/server-scope.ts | 24 +- .../app/src/utils/session-message.test.ts | 4 +- packages/app/src/utils/session-route.test.ts | 31 +- packages/app/src/utils/session-route.ts | 13 - .../test-browser/prompt-persistence.test.ts | 79 +- packages/desktop/src/main/index.ts | 11 +- packages/desktop/src/main/ipc.ts | 10 - packages/desktop/src/main/migrate.ts | 91 --- packages/desktop/src/main/onboarding.ts | 16 +- packages/desktop/src/main/server.ts | 26 +- packages/desktop/src/main/store-keys.ts | 1 - packages/desktop/src/preload/index.ts | 4 - packages/desktop/src/preload/types.ts | 5 - packages/desktop/src/renderer/index.tsx | 45 +- packages/desktop/src/renderer/onboarding.tsx | 16 +- packages/ui/src/theme/context.tsx | 30 +- 117 files changed, 2131 insertions(+), 3542 deletions(-) delete mode 100644 packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts delete mode 100644 packages/app/e2e/regression/session-timeline-tool-state.spec.ts delete mode 100644 packages/app/e2e/regression/session-todo-dock-navigation.spec.ts delete mode 100644 packages/desktop/src/main/migrate.ts diff --git a/packages/app/e2e/performance/timeline-stability/adverse.spec.ts b/packages/app/e2e/performance/timeline-stability/adverse.spec.ts index 7ce1be90645..8a0f1b56763 100644 --- a/packages/app/e2e/performance/timeline-stability/adverse.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/adverse.spec.ts @@ -9,6 +9,7 @@ import { import { assistantMessage, partUpdated, + renderedPartID, setupTimeline, shell, textPart, @@ -97,13 +98,15 @@ test.describe("timeline adverse visual stability", () => { element.scrollTop = 0 }) await page.waitForTimeout(300) - const trigger = page.locator(`[data-timeline-part-id="${targetID}"] [data-slot="collapsible-trigger"]`) + const trigger = page.locator( + `[data-timeline-part-id="${renderedPartID(targetID)}"] [data-slot="collapsible-trigger"]`, + ) await expect(trigger).toBeVisible() await trigger.click() await expect(trigger).toHaveAttribute("aria-expanded", "true") await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight)) - await expect(page.locator(`[data-timeline-part-id="${targetID}"]`)).toHaveCount(0) + await expect(page.locator(`[data-timeline-part-id="${renderedPartID(targetID)}"]`)).toHaveCount(0) await scroller.evaluate((element) => (element.scrollTop = 0)) await expect(trigger).toBeVisible() await expect(trigger).toHaveAttribute("aria-expanded", "true") @@ -127,13 +130,16 @@ test.describe("timeline adverse visual stability", () => { cpuRate: 4, }) await waitForVisualSettle(page, [ - `[data-timeline-part-id="${shellID}"]`, - `[data-timeline-part-id="${followingID}"]`, + `[data-timeline-part-id="${renderedPartID(shellID)}"]`, + `[data-timeline-part-id="${renderedPartID(followingID)}"]`, ]) const regions = defineVisualRegions({ - shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + shell: { + selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, following: { - selector: `[data-timeline-part-id="${followingID}"]`, + selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`, closest: '[data-timeline-row="AssistantPart"]', }, }) @@ -184,10 +190,13 @@ test.describe("timeline adverse visual stability", () => { }) const group = `[data-timeline-part-ids="${contextIDs.join(",")}"]` const regions = defineVisualRegions({ - shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + shell: { + selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' }, following: { - selector: `[data-timeline-part-id="${followingID}"]`, + selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`, closest: '[data-timeline-row="AssistantPart"]', }, }) diff --git a/packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts deleted file mode 100644 index b3438b77c06..00000000000 --- a/packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - defineVisualRegions, - reportVisualStability, - startVisualProbe, - stopVisualProbe, - visualPlan, -} from "../../utils/visual-stability" -import { - assistantID, - assistantMessage, - event, - partUpdated, - setupTimeline, - textPart, - toolPart, - userMessage, - waitForVisualSettle, -} from "./fixture" - -const inputs = { - read: { filePath: "src/a.ts", offset: 0, limit: 120 }, - glob: { path: ".", pattern: "**/*.ts" }, - grep: { path: ".", pattern: "stable", include: "*.ts" }, - list: { path: "src" }, -} - -test("appends context operations while the group is expanded", async ({ page }, testInfo) => { - const firstID = "prt_append_01_read" - const followingID = "prt_append_99_following" - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([toolPart(firstID, "read", "running", inputs.read), textPart(followingID, "Following append")], { - completed: false, - }), - ], - cpuRate: 4, - }) - const initialGroup = `[data-timeline-part-ids="${firstID}"]` - await page.locator(`${initialGroup} [data-slot="collapsible-trigger"]`).click() - await waitForVisualSettle(page, [initialGroup, `[data-timeline-part-id="${followingID}"]`]) - const regions = defineVisualRegions({ - context: { - selector: '[data-timeline-part-ids^="prt_append_01_read"]', - closest: '[data-timeline-row="AssistantPart"]', - }, - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - }) - await startVisualProbe(page, regions) - await timeline.send(partUpdated(toolPart("prt_append_02_glob", "glob", "running", inputs.glob)), 180) - await timeline.send(partUpdated(toolPart("prt_append_03_grep", "grep", "completed", inputs.grep)), 240) - await timeline.send(partUpdated(toolPart("prt_append_04_list", "list", "completed", inputs.list)), 500) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "context-append", - trace, - visualPlan( - regions, - [ - { type: "required", regions: ["context", "following"] }, - { type: "unique", regions: ["context", "following"] }, - { type: "stable", regions: ["context", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: ["following"], maxPositionReversals: 0 }, - { type: "label-stability", regions: "all" }, - { type: "preserve-bottom-anchor" }, - { type: "flow", regions: ["context", "following"] }, - ], - { perMarker: true }, - ), - ) - await expect( - page.locator( - '[data-timeline-part-ids="prt_append_01_read,prt_append_02_glob,prt_append_03_grep,prt_append_04_list"]', - ), - ).toBeVisible() - await expect( - page.locator('[data-timeline-part-ids^="prt_append_01_read"] [data-slot="collapsible-trigger"]'), - ).toHaveAttribute("aria-expanded", "true") -}) - -test("splits and merges context groups when a middle text part changes", async ({ page }, testInfo) => { - const textID = "prt_split_02_text" - const followingID = "prt_split_99_following" - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([ - toolPart("prt_split_01_read", "read", "completed", inputs.read), - textPart(textID, "Boundary"), - toolPart("prt_split_03_glob", "glob", "completed", inputs.glob), - textPart(followingID, "Following split groups"), - ]), - ], - cpuRate: 4, - }) - const regions = defineVisualRegions({ - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - }) - await startVisualProbe(page, regions) - await timeline.send( - event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: textID }), - 500, - ) - await expect(page.locator('[data-timeline-part-ids="prt_split_01_read,prt_split_03_glob"]')).toBeVisible() - await timeline.send(partUpdated(textPart(textID, "Boundary restored")), 500) - await expect(page.locator('[data-timeline-part-ids="prt_split_01_read"]')).toBeVisible() - await expect(page.locator('[data-timeline-part-ids="prt_split_03_glob"]')).toBeVisible() - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "context-split-merge", - trace, - visualPlan( - regions, - [ - { type: "required", regions: ["following"] }, - { type: "unique", regions: ["following"] }, - { type: "stable", regions: ["following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 1 }, - { type: "label-stability", regions: "all" }, - ], - { perMarker: true }, - ), - ) -}) - -test("removing the first context member replaces the group once without overlapping following content", async ({ - page, -}, testInfo) => { - const ids = ["prt_key_01_read", "prt_key_02_glob", "prt_key_03_grep"] - const followingID = "prt_key_99_following" - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([ - toolPart(ids[0]!, "read", "completed", inputs.read), - toolPart(ids[1]!, "glob", "completed", inputs.glob), - toolPart(ids[2]!, "grep", "completed", inputs.grep), - textPart(followingID, "Following replaced group"), - ]), - ], - cpuRate: 4, - }) - const original = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`) - const originalRowKey = await original.evaluate((element) => - element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key"), - ) - await original.locator('[data-slot="collapsible-trigger"]').click() - await expect(original.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true") - const regions = defineVisualRegions({ - context: { - selector: '[data-timeline-part-ids*="prt_key_02_glob"]', - closest: '[data-timeline-row="AssistantPart"]', - }, - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - }) - await startVisualProbe(page, regions) - await timeline.send( - event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: ids[0] }), - 500, - ) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "context-first-remove", - trace, - visualPlan(regions, [ - { type: "required", regions: ["context", "following"] }, - { type: "unique", regions: ["context", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 0 }, - { type: "label-stability", regions: "all" }, - { type: "flow", regions: ["context", "following"] }, - ]), - ) - await expect(page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`)).toBeVisible() - expect( - await page - .locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`) - .evaluate((element) => element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key")), - ).toBe(originalRowKey) - await expect( - page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"] [data-slot="collapsible-trigger"]`), - ).toHaveAttribute("aria-expanded", "true") -}) diff --git a/packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts index 3455438a736..2a2ae06e735 100644 --- a/packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts @@ -9,6 +9,7 @@ import { import { assistantMessage, partUpdated, + renderedPartID, setupTimeline, shell, textPart, @@ -34,13 +35,16 @@ for (const deviceScaleFactor of [1, 1.25]) { seedHistory: true, }) await waitForVisualSettle(page, [ - `[data-timeline-part-id="${shellID}"]`, - `[data-timeline-part-id="${followingID}"]`, + `[data-timeline-part-id="${renderedPartID(shellID)}"]`, + `[data-timeline-part-id="${renderedPartID(followingID)}"]`, ]) const regions = defineVisualRegions({ - shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + shell: { + selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, following: { - selector: `[data-timeline-part-id="${followingID}"]`, + selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`, closest: '[data-timeline-row="AssistantPart"]', }, }) @@ -71,13 +75,16 @@ for (const reducedMotion of [true]) { seedHistory: true, }) await waitForVisualSettle(page, [ - `[data-timeline-part-id="${shellID}"]`, - `[data-timeline-part-id="${followingID}"]`, + `[data-timeline-part-id="${renderedPartID(shellID)}"]`, + `[data-timeline-part-id="${renderedPartID(followingID)}"]`, ]) const regions = defineVisualRegions({ - shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + shell: { + selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, following: { - selector: `[data-timeline-part-id="${followingID}"]`, + selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`, closest: '[data-timeline-row="AssistantPart"]', }, }) diff --git a/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts index e0f0d72233b..210aa4ddcae 100644 --- a/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts @@ -9,6 +9,7 @@ import { import { assistantMessage, partUpdated, + renderedPartID, setupTimeline, textPart, toolPart, @@ -43,11 +44,17 @@ for (const profile of profiles) { settings: { editToolPartsExpanded: true }, cpuRate: 4, }) - await waitForVisualSettle(page, [`[data-timeline-part-id="${partID}"]`, `[data-timeline-part-id="${followingID}"]`]) + await waitForVisualSettle(page, [ + `[data-timeline-part-id="${renderedPartID(partID)}"]`, + `[data-timeline-part-id="${renderedPartID(followingID)}"]`, + ]) const regions = defineVisualRegions({ - tool: { selector: `[data-timeline-part-id="${partID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + tool: { + selector: `[data-timeline-part-id="${renderedPartID(partID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, following: { - selector: `[data-timeline-part-id="${followingID}"]`, + selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`, closest: '[data-timeline-row="AssistantPart"]', }, }) diff --git a/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts b/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts index 798bf0df3b2..f6bd6b0009a 100644 --- a/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts @@ -9,6 +9,7 @@ import { import { assistantMessage, partUpdated, + renderedPartID, setupTimeline, textPart, toolPart, @@ -35,12 +36,23 @@ test("adds patch files incrementally without resetting outer expansion", async ( cpuRate: 4, seedHistory: true, }) - const trigger = page.locator(`[data-timeline-part-id="${patchID}"] [data-slot="collapsible-trigger"]`).first() + const trigger = page + .locator(`[data-timeline-part-id="${renderedPartID(patchID)}"] [data-slot="collapsible-trigger"]`) + .first() await expect(trigger).toHaveAttribute("aria-expanded", "true") - await waitForVisualSettle(page, [`[data-timeline-part-id="${patchID}"]`, `[data-timeline-part-id="${followingID}"]`]) + await waitForVisualSettle(page, [ + `[data-timeline-part-id="${renderedPartID(patchID)}"]`, + `[data-timeline-part-id="${renderedPartID(followingID)}"]`, + ]) const regions = defineVisualRegions({ - patch: { selector: `[data-timeline-part-id="${patchID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + patch: { + selector: `[data-timeline-part-id="${renderedPartID(patchID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, + following: { + selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, }) await startVisualProbe(page, regions) const second = patchFile("src/b.ts", "add") diff --git a/packages/app/e2e/performance/timeline-stability/fixture.test.ts b/packages/app/e2e/performance/timeline-stability/fixture.test.ts index b003645e640..61d065fcb46 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.test.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.test.ts @@ -17,19 +17,28 @@ describe("timeline fixture validation", () => { test("rejects malformed SDK values at runtime", () => { expect(() => assistantMessage([], { - error: { name: "APIError", data: { message: "failed" } } as never, + error: { type: "APIError", message: 1 } as never, }), ).toThrow() expect(() => validateTimelineEvent({ - directory: "C:/OpenCode/TimelineStability", - payload: { - id: "evt_invalid_status", - type: "session.status", - properties: { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } }, - }, + id: "evt_invalid_status", + created: 1, + type: "session.status", + data: { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } }, }), ).toThrow() + expect(() => validateTimelineMessages([{ ...userMessage(), id: "invalid" } as never])).toThrow() + expect(() => validateTimelineMessages([{ ...userMessage(), time: { created: "invalid" } } as never])).toThrow() + expect(() => + validateTimelineMessages([ + userMessage(), + { + ...assistantMessage(), + content: [{ type: "tool", id: "call_invalid", name: "bash", state: { status: "completed" } }], + } as never, + ]), + ).toThrow() }) test("rejects duplicate IDs and orphan assistants", () => { @@ -42,8 +51,8 @@ describe("timeline fixture validation", () => { test("assigns deterministic event IDs", () => { const first = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "busy" } }) const second = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "idle" } }) - expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/) - expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1) + expect(first.id).toMatch(/^evt_timeline_\d{4}$/) + expect(Number(second.id.slice(-4))).toBe(Number(first.id.slice(-4)) + 1) }) }) diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index 4df5e412961..8163772f1e2 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -1,9 +1,16 @@ import { base64Encode } from "@opencode-ai/core/util/encode" -import { Event } from "@opencode-ai/schema/event" -import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event" -import { SessionV1 } from "@opencode-ai/schema/session-v1" -import type { SessionInfo, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise" -import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types" +import type { + JsonValue, + OpenCodeEvent, + SessionInfo, + SessionMessageAssistant, + SessionMessageInfo, + SessionMessageUser, + SessionStatus, + SessionStructuredError, +} from "@opencode-ai/client/promise" +import { EventManifest } from "@opencode-ai/schema/event-manifest" +import { SessionMessage } from "@opencode-ai/schema/session-message" import { expect, type Page } from "@playwright/test" import { Schema } from "effect" import { mockOpenCodeServer } from "../../utils/mock-server" @@ -18,50 +25,80 @@ export const assistantID = "msg_1001_timeline_assistant" export const title = "Timeline visual stability" export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` +const tokens = { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } } + type Session = SessionInfo -type GlobalEvent = { - directory: string - project?: string - workspace?: string - payload: { - id: string - type: string - properties: Record - } +type TextSeed = { + id: string + type: "text" + text: string + messageID?: string +} +type FileSeed = { + id: string + type: "file" + mime: string + filename?: string + url: string + source?: { type: string; path?: string; text?: { value: string; start: number; end: number } } +} +type AgentSeed = { + id: string + type: "agent" + name: string + source?: { value: string; start: number; end: number } +} +type ReasoningSeed = { + id: string + type: "reasoning" + text: string + time?: { start: number; end?: number } + metadata?: Record + messageID?: string +} +type ToolSeed = { + id: string + type: "tool" + callID: string + tool: string + messageID?: string + executed?: boolean + providerState?: Record + providerResultState?: Record + state: + | { status: "pending"; input: Record; raw: string } + | { + status: "running" + input: Record + title?: string + metadata: Record + time: { start: number } + } + | { + status: "completed" + input: Record + output: string + title: string + metadata: Record + time: { start: number; end: number } + } + | { + status: "error" + input: Record + error: string + metadata: Record + time: { start: number; end: number } + } } -type TimelineProperties = { - "message.updated": { sessionID: string; info: Message } - "message.removed": { sessionID: string; messageID: string } - "message.part.updated": { sessionID: string; part: Part; time: number } - "message.part.removed": { sessionID: string; messageID: string; partID: string } - "message.part.delta": { sessionID: string; messageID: string; partID: string; field: string; delta: string } - "session.status": { sessionID: string; status: SessionStatus } -} -type TimelinePayload = { - [Type in keyof TimelineProperties]: { id: string; type: Type; properties: TimelineProperties[Type] } -}[keyof TimelineProperties] - -type DeepReadonly = Value extends readonly unknown[] - ? { readonly [Key in keyof Value]: DeepReadonly } - : Value extends object - ? { readonly [Key in keyof Value]: DeepReadonly } - : Value - -export type TimelineEvent = DeepReadonly & { payload: TimelinePayload }> -export type EventPayload = TimelineEvent -export type ToolStatus = ToolState["status"] -export type TimelineMessage = { info: UserMessage; parts: Part[] } | { info: AssistantMessage; parts: Part[] } - -type UserPart = Extract -type AssistantPart = Exclude -type OwnedPart = Owner extends "user" ? UserPart : AssistantPart -export type PartSeed = - OwnedPart extends infer Candidate - ? Candidate extends Part - ? Omit - : never - : never +export type TimelineMessage = SessionMessageUser | SessionMessageAssistant +export type TimelineEvent = OpenCodeEvent | readonly OpenCodeEvent[] +export type EventPayload = OpenCodeEvent +export type ToolStatus = ToolSeed["state"]["status"] +export type PartSeed = Owner extends "user" + ? TextSeed | FileSeed | AgentSeed + : TextSeed | ReasoningSeed | ToolSeed type ToolOptions = State extends "pending" ? { output?: never; title?: never; metadata?: never; error?: never } @@ -71,26 +108,18 @@ type ToolOptions = State extends "pending" ? { error?: string; metadata?: Record; output?: never; title?: never } : { output?: string; title?: string; metadata?: Record; error?: never } -const decodeOptions = { errors: "all", onExcessProperty: "error" } as const -const decodeMessage = Schema.decodeUnknownSync(SessionV1.WithParts) -const decodePart = Schema.decodeUnknownSync(SessionV1.Part) -const decodeStatus = Schema.decodeUnknownSync(SessionStatusEvent.Info) -const timelineEventSchema = Schema.Union([ - eventSchema("message.updated", SessionV1.Event.MessageUpdated.data), - eventSchema("message.removed", SessionV1.Event.MessageRemoved.data), - eventSchema("message.part.updated", SessionV1.Event.PartUpdated.data), - eventSchema("message.part.removed", SessionV1.Event.PartRemoved.data), - eventSchema("message.part.delta", SessionV1.Event.PartDelta.data), - eventSchema("session.status", SessionStatusEvent.Status.data), -]) -const decodeEvent = Schema.decodeUnknownSync(timelineEventSchema) +type PartRef = { messageID: string; type: "text" | "reasoning" | "tool"; ordinal?: number } +const partRefs = new Map() +const nextOrdinals = new Map() +const startedParts = new Set() +const toolStates = new Map() let eventSequence = 0 export async function setupTimeline( page: Page, input: { messages?: TimelineMessage[] - currentMessages?: SessionMessageInfo[] + sessionMessages?: SessionMessageInfo[] sessionStatus?: Record settings?: Record sessions?: Session[] @@ -105,38 +134,22 @@ export async function setupTimeline( ) { const sessions = input.sessions ?? [session()] const messages = - input.currentMessages ?? + input.sessionMessages ?? validateTimelineMessages([ ...(input.seedHistory ? historyMessages(18) : []), ...(input.messages ?? [userMessage(), assistantMessage()]), ]) - const active = messages.findLast((message) => - "info" in message ? message.info.role === "assistant" : message.type === "assistant", - ) - const initialStatus = decodeStatus( - active && - ("info" in active - ? active.info.role === "assistant" && active.info.time.completed === undefined - : active.type === "assistant" && active.time.completed === undefined) - ? { type: "busy" } - : { type: "idle" }, - decodeOptions, - ) - const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` - const transport = await installSseTransport(page, { - server, - retry: input.eventRetry ?? 20, - }) + const active = messages.findLast((message) => message.type === "assistant") + const initialStatus: SessionStatus = + active?.type === "assistant" && active.time.completed === undefined ? { type: "busy" } : { type: "idle" } + const transport = await installSseTransport(page, { server, retry: input.eventRetry ?? 20 }) await mockOpenCodeServer(page, { - protocol: "v2", directory, project: project(), provider: provider(), sessions, sessionStatus: input.sessionStatus ?? { [sessionID]: initialStatus }, - pageMessages: () => ({ - items: messages, - }), + pageMessages: () => ({ items: messages }), }) await page.addInitScript((settings) => { localStorage.setItem( @@ -169,7 +182,7 @@ export async function setupTimeline( mobile: false, }) } - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) await expectSessionReady(page, { server, sessionID, title }) await transport.waitForConnection() if (input.cpuRate && input.cpuRate > 1) { @@ -179,15 +192,25 @@ export async function setupTimeline( return { transport, - async send(event: TimelineEvent, delay = 0) { - const valid = validateTimelineEvent(event) - await transport.send(valid, { marker: describeEvent(valid) }) + async send(input: TimelineEvent, delay = 0) { + const events = timelineEvents(input) + if (events.length === 1) await transport.send(events[0]!, { marker: describeEvent(events[0]!) }) + if (events.length > 1) + await transport.burst( + events, + events.map((item) => ({ marker: describeEvent(item) })), + ) if (delay) await page.waitForTimeout(delay) }, async sendAll(sequence: { event: TimelineEvent; delay: number }[]) { for (const item of sequence) { - const valid = validateTimelineEvent(item.event) - await transport.send(valid, { marker: describeEvent(valid) }) + const events = timelineEvents(item.event) + if (events.length === 1) await transport.send(events[0]!, { marker: describeEvent(events[0]!) }) + if (events.length > 1) + await transport.burst( + events, + events.map((event) => ({ marker: describeEvent(event) })), + ) await page.waitForTimeout(item.delay) } }, @@ -207,72 +230,63 @@ export async function setupTimeline( ) }, async waitForPart(partID: string) { - const part = page.locator(`[data-timeline-part-id="${partID}"]`) + const part = page.locator(`[data-timeline-part-id="${renderedPartID(partID)}"]`) await expect(part).toHaveCount(1) await expect(part).toBeVisible() }, } } -function describeEvent(event: EventPayload) { - if (event.payload.type === "message.part.updated") { - const part = event.payload.properties.part - return [ - event.payload.type, - part.id, - part.type === "tool" ? part.tool : part.type, - part.type === "tool" ? part.state.status : undefined, - ] - .filter(Boolean) - .join(":") - } - if (event.payload.type === "session.status") { - const status = event.payload.properties.status - return [event.payload.type, status.type, status.type === "retry" ? status.attempt : undefined] - .filter((value) => value !== undefined) - .join(":") - } - return event.payload.type +function timelineEvents(input: TimelineEvent) { + return (Array.isArray(input) ? input : [input]).map(validateTimelineEvent) } -export function event( - type: Type, - properties: Extract["properties"], -): TimelineEvent -export function event(type: TimelinePayload["type"], properties: TimelinePayload["properties"]): TimelineEvent { - return validateTimelineEvent({ - directory, - payload: { id: `evt_timeline_${String(++eventSequence).padStart(4, "0")}`, type, properties }, - }) +function describeEvent(event: OpenCodeEvent) { + if (event.type.startsWith("session.tool.")) { + const data = event.data as { id?: string } + return [event.type, data.id].filter(Boolean).join(":") + } + return event.type } -export function validateTimelineEvent(input: unknown): TimelineEvent { - return decodeEvent(input, decodeOptions) as TimelineEvent +export function event( + type: "session.status", + data: Extract["data"], +): OpenCodeEvent { + return makeEvent(type, data) +} + +export function validateTimelineEvent(input: unknown): OpenCodeEvent { + if (!input || typeof input !== "object") throw new Error("Timeline event must be an object") + if (!("type" in input) || typeof input.type !== "string") throw new Error("Timeline event requires a type") + const definition = EventManifest.ServerDefinitions.find((definition) => definition.type === input.type) + if (!definition) throw new Error(`Unknown timeline event: ${input.type}`) + return Schema.decodeUnknownSync(definition)(input) as OpenCodeEvent } export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] { - input.forEach((message) => decodeMessage(message, decodeOptions)) - const messages = [...input] + const messages = input.map((message): TimelineMessage => { + const decoded = Schema.decodeUnknownSync(SessionMessage.Info)(message) + if (decoded.type !== "user" && decoded.type !== "assistant") + throw new Error(`Unsupported timeline message type: ${decoded.type}`) + return message + }) const messageIDs = new Set() - const partIDs = new Set() - const users = new Set(messages.filter((message) => message.info.role === "user").map((message) => message.info.id)) - + let parentID: string | undefined messages.forEach((message) => { - if (messageIDs.has(message.info.id)) - throw new Error(`Timeline fixture has duplicate message ID: ${message.info.id}`) - messageIDs.add(message.info.id) - if (message.info.role === "assistant" && !users.has(message.info.parentID)) - throw new Error(`Timeline assistant ${message.info.id} must reference a parent user in the fixture`) - message.parts.forEach((part) => { - if (part.sessionID !== message.info.sessionID || part.messageID !== message.info.id) - throw new Error(`Timeline part ${part.id} ownership does not match message ${message.info.id}`) - if (message.info.role === "user" && !["text", "file", "agent", "subtask"].includes(part.type)) - throw new Error(`Timeline user message ${message.info.id} cannot own ${part.type} part ${part.id}`) - if (message.info.role === "assistant" && ["agent", "subtask"].includes(part.type)) - throw new Error(`Timeline assistant message ${message.info.id} cannot own ${part.type} part ${part.id}`) - if (partIDs.has(part.id)) throw new Error(`Timeline fixture has duplicate part ID: ${part.id}`) - partIDs.add(part.id) - }) + if (messageIDs.has(message.id)) throw new Error(`Timeline fixture has duplicate message ID: ${message.id}`) + messageIDs.add(message.id) + if (message.type === "user") parentID = message.id + if (message.type === "assistant") { + const expected = typeof message.metadata?.parentID === "string" ? message.metadata.parentID : parentID + if (!expected || expected !== parentID) + throw new Error(`Timeline assistant ${message.id} must reference a parent user in the fixture`) + message.content.forEach((part) => { + if (part.type !== "tool") return + if (partRefs.has(part.id) && partRefs.get(part.id)?.messageID !== message.id) + throw new Error(`Timeline fixture has duplicate part ID: ${part.id}`) + }) + } }) return messages } @@ -334,22 +348,82 @@ export function historyMessages(count: number): TimelineMessage[] { }).flat() } -export function partUpdated(part: Part | PartSeed<"assistant">) { - const owned = "messageID" in part ? part : { ...part, sessionID, messageID: assistantID } - decodePart(owned, decodeOptions) - return event("message.part.updated", { - sessionID, - part: owned, - time: 1700000002000, - }) +export function partUpdated(part: PartSeed<"assistant">): readonly OpenCodeEvent[] { + const messageID = part.messageID ?? assistantID + const started = startedParts.has(part.id) + const ref = partRef(part.id, messageID, part.type) + if (part.type === "text") { + startedParts.add(part.id) + return [ + ...(started + ? [] + : [makeEvent("session.text.started", { sessionID, assistantMessageID: messageID, ordinal: ref.ordinal! })]), + makeEvent("session.text.ended", { + sessionID, + assistantMessageID: messageID, + ordinal: ref.ordinal!, + text: part.text, + }), + ] + } + if (part.type === "reasoning") { + startedParts.add(part.id) + return [ + ...(started + ? [] + : [ + makeEvent("session.reasoning.started", { + sessionID, + assistantMessageID: messageID, + ordinal: ref.ordinal!, + state: jsonRecord(part.metadata), + }), + ]), + makeEvent("session.reasoning.ended", { + sessionID, + assistantMessageID: messageID, + ordinal: ref.ordinal!, + text: part.text, + state: jsonRecord(part.metadata), + }), + ] + } + return toolEvents(part, messageID) +} + +export function renderedPartID(partID: string) { + const ref = partRefs.get(partID) + if (!ref || ref.type === "tool") return partID + return `${ref.messageID}:${ref.type}:${ref.ordinal}` } export function partDelta(partID: string, delta: string, messageID = assistantID) { - return event("message.part.delta", { sessionID, messageID, partID, field: "text", delta }) + const ref = partRefs.get(partID) + if (!ref || ref.type !== "text" || ref.ordinal === undefined) throw new Error(`Unknown text part: ${partID}`) + return makeEvent("session.text.delta", { + sessionID, + assistantMessageID: messageID, + ordinal: ref.ordinal, + delta, + }) } -export function messageUpdated(info: Message) { - return event("message.updated", { sessionID, info }) +export function messageUpdated(info: SessionMessageAssistant) { + if (info.error) + return makeEvent("session.step.failed", { + sessionID, + assistantMessageID: info.id, + error: info.error, + cost: info.cost, + tokens: info.tokens, + }) + return makeEvent("session.step.ended", { + sessionID, + assistantMessageID: info.id, + finish: info.finish ?? "stop", + cost: info.cost ?? 0, + tokens: info.tokens ?? tokens, + }) } export function status(type: SessionStatus["type"], attempt = 1) { @@ -361,28 +435,45 @@ export function status(type: SessionStatus["type"], attempt = 1) { export function userMessage( parts?: PartSeed<"user">[], - input: { id?: string; summary?: UserMessage["summary"]; created?: number } = {}, -): Extract { + input: { id?: string; summary?: unknown; created?: number } = {}, +): SessionMessageUser { const id = input.id ?? userID const seeds = parts ?? [userText("Build the timeline stability matrix.", { id: `prt_${id}_text` })] - const message = { - info: { - id, - sessionID, - role: "user", - time: { created: input.created ?? 1700000000000 }, - summary: input.summary ?? { diffs: [] }, - agent: "build", - model, - }, - parts: seeds.map((part) => ({ - ...part, - sessionID, - messageID: id, - })), - } satisfies Extract - decodeMessage(message, decodeOptions) - return message + return { + id, + type: "user", + time: { created: input.created ?? 1700000000000 }, + text: seeds.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"), + files: seeds.flatMap((part) => { + if (part.type !== "file") return [] + const mention = part.source?.text + ? { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end } + : undefined + return [ + { + data: part.url.match(/^data:[^,]*;base64,(.*)$/)?.[1] ?? "", + mime: part.mime, + source: part.url.startsWith("data:") + ? ({ type: "inline" } as const) + : ({ type: "uri", uri: part.source?.path ?? part.url } as const), + ...(part.filename ? { name: part.filename } : {}), + ...(mention ? { mention } : {}), + }, + ] + }), + agents: seeds.flatMap((part) => { + if (part.type !== "agent") return [] + return [ + { + name: part.name, + ...(part.source + ? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } } + : {}), + }, + ] + }), + ...(input.summary === undefined ? {} : { metadata: { summary: input.summary as JsonValue } }), + } } export function assistantMessage( @@ -391,49 +482,43 @@ export function assistantMessage( id?: string parentID?: string completed?: boolean - error?: AssistantMessage["error"] + error?: SessionStructuredError created?: number } = {}, -): Extract { +): SessionMessageAssistant { + if (input.error && (typeof input.error.type !== "string" || typeof input.error.message !== "string")) + throw new Error("Invalid assistant error") const id = input.id ?? assistantID - const message = { - info: { - id, - sessionID, - role: "assistant", - time: { - created: input.created ?? 1700000001000, - ...(input.completed === false ? {} : { completed: (input.created ?? 1700000001000) + 1_000 }), - }, - parentID: input.parentID ?? userID, - modelID: model.modelID, - providerID: model.providerID, - mode: "build", - agent: "build", - path: { cwd: directory, root: directory }, - cost: 0.01, - tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, - variant: "max", - ...(input.error ? { error: input.error } : {}), - }, - parts: parts.map((part) => ({ ...part, sessionID, messageID: id })), - } satisfies Extract - decodeMessage(message, decodeOptions) - return message + const created = input.created ?? 1700000001000 + const ordinals = { text: 0, reasoning: 0 } + const content = parts.map((part) => messageContent(part, id, ordinals)) + nextOrdinals.set(id, ordinals) + return { + id, + type: "assistant", + metadata: { parentID: input.parentID ?? userID }, + time: { created, ...(input.completed === false ? {} : { completed: created + 1_000 }) }, + model: { id: model.modelID, providerID: model.providerID, variant: model.variant }, + agent: "build", + content, + cost: 0.01, + tokens, + ...(input.completed === false ? {} : { finish: "stop" as const }), + ...(input.error ? { error: input.error } : {}), + } } -export function userText( - text: string, - input: Partial, { type: "text" }>, "type" | "text">> = {}, -): Extract, { type: "text" }> { +export function userText(text: string, input: Partial> = {}): TextSeed { return { id: "prt_user_text", type: "text", text, ...input } } -export function textPart(id: string, text: string): Extract, { type: "text" }> { +export function textPart(id: string, text: string): TextSeed { + partRef(id, assistantID, "text") return { id, type: "text", text } } -export function reasoningPart(id: string, text: string): Extract, { type: "reasoning" }> { +export function reasoningPart(id: string, text: string): ReasoningSeed { + partRef(id, assistantID, "reasoning") return { id, type: "reasoning", text, time: { start: 1700000001000 } } } @@ -443,35 +528,35 @@ export function toolPart( state: "pending", input: Record, options?: ToolOptions<"pending">, -): Omit +): ToolSeed export function toolPart( id: string, tool: string, state: "running", input: Record, options?: ToolOptions<"running">, -): Omit +): ToolSeed export function toolPart( id: string, tool: string, state: "completed", input: Record, options?: ToolOptions<"completed">, -): Omit +): ToolSeed export function toolPart( id: string, tool: string, state: "error", input: Record, options?: ToolOptions<"error">, -): Omit +): ToolSeed export function toolPart( id: string, tool: string, state: ToolStatus, input: Record, options: ToolOptions = {}, -): Omit { +): ToolSeed { const base = { id, type: "tool" as const, callID: id, tool } if (state === "pending") return { ...base, state: { status: state, input, raw: "" } } if (state === "running") @@ -509,12 +594,7 @@ export function toolPart( } } -export function shell( - id: string, - state: ToolStatus, - output = "", - command = `echo ${id}`, -): Omit { +export function shell(id: string, state: ToolStatus, output = "", command = `echo ${id}`): ToolSeed { if (state === "pending") return toolPart(id, "bash", state, { command }) if (state === "running") return toolPart(id, "bash", state, { command }, { title: command, metadata: { command, output } }) @@ -523,7 +603,7 @@ export function shell( return toolPart(id, "bash", state, { command }, { title: command, output, metadata: { command, output } }) } -export function completedAssistantInfo(info: AssistantMessage): AssistantMessage { +export function completedAssistantInfo(info: SessionMessageAssistant): SessionMessageAssistant { return { ...info, time: { ...info.time, completed: 1700000003000 } } } @@ -551,16 +631,200 @@ export function session(input: Partial = {}): Session { } } -function eventSchema< - const Type extends TimelinePayload["type"], - const Properties extends Schema.Codec, ->(type: Type, properties: Properties) { - return Schema.Struct({ - directory: Schema.String, - project: Schema.optional(Schema.String), - workspace: Schema.optional(Schema.String), - payload: Schema.Struct({ id: Event.ID, type: Schema.Literal(type), properties }), - }) +function messageContent( + part: PartSeed<"assistant">, + messageID: string, + ordinals: { text: number; reasoning: number }, +): SessionMessageAssistant["content"][number] { + if (part.type === "tool") { + partRefs.set(part.id, { messageID, type: part.type }) + toolStates.set(part.callID, part.state.status) + } else { + partRefs.set(part.id, { messageID, type: part.type, ordinal: ordinals[part.type]++ }) + startedParts.add(part.id) + } + if (part.type === "text") return { type: "text", text: part.text } + if (part.type === "reasoning") + return { + type: "reasoning", + text: part.text, + state: jsonRecord(part.metadata), + time: part.time + ? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) } + : undefined, + } + const state = part.state + const time = "time" in state ? state.time : undefined + const completed = state.status === "completed" || state.status === "error" ? state.time.end : undefined + const base = { + type: "tool" as const, + id: part.callID, + name: part.tool, + time: { + created: time?.start ?? 1700000001000, + ...(time?.start === undefined ? {} : { ran: time.start }), + ...(completed === undefined ? {} : { completed }), + }, + ...(part.executed === undefined ? {} : { executed: part.executed }), + ...(part.providerState ? { providerState: jsonRecord(part.providerState) } : {}), + ...(part.providerResultState ? { providerResultState: jsonRecord(part.providerResultState) } : {}), + } + if (state.status === "pending") return { ...base, state: { status: "streaming", input: state.raw } } + if (state.status === "running") + return { + ...base, + state: { status: "running", input: jsonRecord(state.input), metadata: jsonRecord(state.metadata) }, + } + if (state.status === "error") + return { + ...base, + state: { + status: "error", + input: jsonRecord(state.input), + error: { type: "ToolError", message: state.error }, + metadata: jsonRecord(state.metadata), + }, + } + return { + ...base, + state: { + status: "completed", + input: jsonRecord(state.input), + content: [{ type: "text", text: state.output }], + metadata: jsonRecord(state.metadata), + }, + } +} + +function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[] { + const previous = toolStates.get(part.callID) + if (previous === "completed" || previous === "error") return [] + + const events: OpenCodeEvent[] = [] + if (!previous) { + events.push( + makeEvent("session.tool.input.started", { + sessionID, + assistantMessageID: messageID, + id: part.callID, + name: part.tool, + }), + ) + } + if (part.state.status === "pending") { + toolStates.set(part.callID, part.state.status) + return events + } + if (!previous || previous === "pending") { + events.push( + makeEvent("session.tool.input.ended", { + sessionID, + assistantMessageID: messageID, + id: part.callID, + text: JSON.stringify(part.state.input), + }), + makeEvent("session.tool.called", { + sessionID, + assistantMessageID: messageID, + id: part.callID, + input: part.state.input, + executed: part.executed ?? true, + state: jsonRecord(part.providerState), + }), + ) + } + if (part.state.status === "running") { + if (previous === "running" || Object.keys(part.state.metadata).length) + events.push( + makeEvent("session.tool.progress", { + sessionID, + assistantMessageID: messageID, + id: part.callID, + metadata: jsonRecord(part.state.metadata), + }), + ) + toolStates.set(part.callID, part.state.status) + return events + } + if (part.state.status === "error") { + events.push( + makeEvent("session.tool.failed", { + sessionID, + assistantMessageID: messageID, + id: part.callID, + error: { type: "ToolError", message: part.state.error }, + metadata: jsonRecord(part.state.metadata), + executed: part.executed ?? true, + resultState: jsonRecord(part.providerResultState), + }), + ) + toolStates.set(part.callID, part.state.status) + return events + } + events.push( + makeEvent("session.tool.success", { + sessionID, + assistantMessageID: messageID, + id: part.callID, + content: [{ type: "text", text: part.state.output }], + metadata: jsonRecord(part.state.metadata), + executed: part.executed ?? true, + resultState: jsonRecord(part.providerResultState), + }), + ) + toolStates.set(part.callID, part.state.status) + return events +} + +function partRef(id: string, messageID: string, type: PartRef["type"]): PartRef { + const current = partRefs.get(id) + if (current) return current + if (type === "tool") { + const ref = { messageID, type } satisfies PartRef + partRefs.set(id, ref) + return ref + } + const next = nextOrdinals.get(messageID) ?? { text: 0, reasoning: 0 } + const ref = { messageID, type, ordinal: next[type]++ } satisfies PartRef + nextOrdinals.set(messageID, next) + partRefs.set(id, ref) + return ref +} + +function makeEvent( + type: Type, + data: Extract["data"], +): OpenCodeEvent { + const id = `evt_timeline_${String(++eventSequence).padStart(4, "0")}` + const base = { id, created: 1700000002000 + eventSequence, type, data, location: { directory } } + const definition = EventManifest.ServerDefinitions.find((definition) => definition.type === type) + if (!definition) throw new Error(`Unknown timeline event: ${type}`) + const input = + definition.durability === "durable" + ? { + ...base, + durable: { aggregateID: sessionID, seq: eventSequence, version: definition.durable.version }, + } + : base + return Schema.decodeUnknownSync(definition)(input) as unknown as OpenCodeEvent +} + +function jsonRecord(value: Record | undefined): Record { + if (!value) return {} + return Object.fromEntries( + Object.entries(value).flatMap(([key, item]) => { + const next = jsonValue(item) + return next === undefined ? [] : [[key, next]] + }), + ) +} + +function jsonValue(value: unknown): JsonValue | undefined { + if (value === null || typeof value === "string" || typeof value === "boolean") return value + if (typeof value === "number") return Number.isFinite(value) ? value : null + if (Array.isArray(value)) return value.map((item) => jsonValue(item) ?? null) + if (!value || typeof value !== "object") return + return jsonRecord(value as Record) } function provider() { diff --git a/packages/app/e2e/performance/timeline-stability/interaction.spec.ts b/packages/app/e2e/performance/timeline-stability/interaction.spec.ts index 72139a5c6de..767fc418927 100644 --- a/packages/app/e2e/performance/timeline-stability/interaction.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/interaction.spec.ts @@ -6,7 +6,16 @@ import { stopVisualProbe, visualPlan, } from "../../utils/visual-stability" -import { assistantMessage, setupTimeline, shell, textPart, toolPart, userMessage, waitForVisualSettle } from "./fixture" +import { + assistantMessage, + renderedPartID, + setupTimeline, + shell, + textPart, + toolPart, + userMessage, + waitForVisualSettle, +} from "./fixture" test("expands and collapses a long completed shell without overlap", async ({ page }, testInfo) => { const shellID = "prt_interaction_01_shell" @@ -20,11 +29,20 @@ test("expands and collapses a long completed shell without overlap", async ({ pa cpuRate: 4, seedHistory: true, }) - const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`) - await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`]) + const trigger = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"] [data-slot="collapsible-trigger"]`) + await waitForVisualSettle(page, [ + `[data-timeline-part-id="${renderedPartID(shellID)}"]`, + `[data-timeline-part-id="${renderedPartID(followingID)}"]`, + ]) const regions = defineVisualRegions({ - shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + shell: { + selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, + following: { + selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, }) const plan = visualPlan(regions, [ { type: "required", regions: ["shell", "following"] }, @@ -76,7 +94,7 @@ test("expands and collapses a completed context group without overlap", async ({ seedHistory: true, }) const trigger = page.locator(`${group} [data-slot="collapsible-trigger"]`) - await waitForVisualSettle(page, [group, `[data-timeline-part-id="${followingID}"]`]) + await waitForVisualSettle(page, [group, `[data-timeline-part-id="${renderedPartID(followingID)}"]`]) for (const [name, expanded] of [ ["context-expand", true], ["context-collapse", false], @@ -85,7 +103,7 @@ test("expands and collapses a completed context group without overlap", async ({ const regions = defineVisualRegions({ context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' }, following: { - selector: `[data-timeline-part-id="${followingID}"]`, + selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`, closest: '[data-timeline-row="AssistantPart"]', }, }) @@ -144,11 +162,22 @@ test("expands and collapses an edit diff without moving twice", async ({ page }, cpuRate: 4, seedHistory: true, }) - const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first() - await waitForVisualSettle(page, [`[data-timeline-part-id="${editID}"]`, `[data-timeline-part-id="${followingID}"]`]) + const trigger = page + .locator(`[data-timeline-part-id="${renderedPartID(editID)}"] [data-slot="collapsible-trigger"]`) + .first() + await waitForVisualSettle(page, [ + `[data-timeline-part-id="${renderedPartID(editID)}"]`, + `[data-timeline-part-id="${renderedPartID(followingID)}"]`, + ]) const regions = defineVisualRegions({ - edit: { selector: `[data-timeline-part-id="${editID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + edit: { + selector: `[data-timeline-part-id="${renderedPartID(editID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, + following: { + selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, }) await startVisualProbe(page, regions) await trigger.click() @@ -173,64 +202,6 @@ test("expands and collapses an edit diff without moving twice", async ({ page }, ) }) -test("shows all and expands historical diff summary without overlap", async ({ page }, testInfo) => { - const firstUser = userMessage(undefined, { - summary: { - diffs: Array.from({ length: 12 }, (_, index) => ({ - file: `src/diff-${index}.ts`, - status: "modified", - additions: 1, - deletions: 1, - patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`, - })), - }, - }) - const nextUserID = "msg_2000_diff_interaction_user" - await setupTimeline(page, { - messages: [ - firstUser, - assistantMessage(), - userMessage(undefined, { id: nextUserID, created: 1700000010000 }), - assistantMessage([], { - id: "msg_2001_diff_interaction_assistant", - parentID: nextUserID, - created: 1700000011000, - }), - ], - cpuRate: 4, - }) - const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) - await scroller.evaluate((element) => (element.scrollTop = 0)) - const diff = page.locator('[data-timeline-row="DiffSummary"]') - const following = page.locator(`[data-message-id="${nextUserID}"]`).first() - await expect(diff).toBeVisible() - const regions = defineVisualRegions({ - diff: { selector: '[data-timeline-row="DiffSummary"]' }, - following: { selector: `[data-message-id="${nextUserID}"]` }, - }) - await startVisualProbe(page, regions) - await page.getByText(/show all/i).click() - await page.waitForTimeout(500) - await diff.locator('[data-slot="session-turn-diff-trigger"]').first().click() - await page.waitForTimeout(900) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "diff-summary-expand", - trace, - visualPlan(regions, [ - { type: "required", regions: ["diff", "following"] }, - { type: "unique", regions: ["diff", "following"] }, - { type: "stable", regions: ["diff", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 1, maxReversals: 2 }, - { type: "label-stability", regions: "all" }, - { type: "flow", regions: ["diff", "following"] }, - ]), - ) -}) - function lines(count: number) { return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n") } diff --git a/packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts b/packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts index 40688429a33..4c3991ac36a 100644 --- a/packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts @@ -14,6 +14,7 @@ import { partDelta, partUpdated, reasoningPart, + renderedPartID, setupTimeline, shell, status, @@ -50,7 +51,7 @@ test.describe("timeline visual lifecycle stability", () => { prt_shell_long: shellRegion(ids[2]), following: shellRegion(followingID), }) - await waitForVisualSettle(page, [`[data-timeline-part-id="${followingID}"]`]) + await waitForVisualSettle(page, [`[data-timeline-part-id="${renderedPartID(followingID)}"]`]) await startVisualProbe(page, regions) await timeline.sendAll([ { event: partUpdated(shell(ids[0]!, "completed", "")), delay: 180 }, @@ -60,7 +61,7 @@ test.describe("timeline visual lifecycle stability", () => { { event: partUpdated(shell(ids[1]!, "completed", lines(2))), delay: 260 }, { event: partUpdated(shell(ids[2]!, "running", lines(50))), delay: 100 }, { event: partUpdated(shell(ids[2]!, "completed", lines(50))), delay: 450 }, - { event: messageUpdated(completedAssistantInfo(assistant.info)), delay: 100 }, + { event: messageUpdated(completedAssistantInfo(assistant)), delay: 100 }, { event: status("idle"), delay: 700 }, ]) const trace = await stopVisualProbe(page) @@ -84,9 +85,11 @@ test.describe("timeline visual lifecycle stability", () => { { perMarker: true }, ), ) - await expect(page.locator(`[data-timeline-part-id="${ids[2]}"] [data-slot="bash-pre"]`)).toContainText("line 50") + await expect( + page.locator(`[data-timeline-part-id="${renderedPartID(ids[2])}"] [data-slot="bash-pre"]`), + ).toContainText("line 50") - const short = page.locator(`[data-timeline-part-id="${ids[1]}"]`) + const short = page.locator(`[data-timeline-part-id="${renderedPartID(ids[1])}"]`) await short.locator('[data-slot="collapsible-trigger"]').click() await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false") await timeline.send(partUpdated(textPart("prt_late_sibling", "A later sibling rerender.")), 250) @@ -106,26 +109,31 @@ test.describe("timeline visual lifecycle stability", () => { }) await timeline.send(status("busy"), 120) await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() + const initialReasoning = reasoningPart(reasoningID, "") + const initialText = textPart(textID, "Starting") const regions = defineVisualRegions({ thinking: { selector: '[data-timeline-row="Thinking"]' }, reasoning: { - selector: `[data-timeline-part-id="${reasoningID}"]`, + selector: `[data-timeline-part-id="${renderedPartID(reasoningID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, + text: { + selector: `[data-timeline-part-id="${renderedPartID(textID)}"]`, closest: '[data-timeline-row="AssistantPart"]', }, - text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, }) await startVisualProbe(page, regions) - await timeline.send(partUpdated(reasoningPart(reasoningID, "")), 100) - await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0) + await timeline.send(partUpdated(initialReasoning), 100) + await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0) await timeline.send(partUpdated(reasoningPart(reasoningID, "## Planning\n\nChecking the visible timeline.")), 160) await timeline.waitForPart(reasoningID) await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) - await timeline.send(partUpdated(textPart(textID, "Starting")), 100) + await timeline.send(partUpdated(initialText), 100) await timeline.send(partDelta(textID, " **stable"), 90) await timeline.send(partDelta(textID, " output** with `code` and [a link"), 130) await timeline.send(partDelta(textID, "](https://example.com)."), 220) - await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 120) + await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 120) await timeline.send(status("idle"), 500) const trace = await stopVisualProbe(page) await reportVisualStability( @@ -144,7 +152,7 @@ test.describe("timeline visual lifecycle stability", () => { { type: "flow", regions: ["reasoning", "text"] }, ]), ) - await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("stable output") + await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText("stable output") }) }) @@ -153,5 +161,5 @@ function lines(count: number) { } function shellRegion(id: string) { - return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' } + return { selector: `[data-timeline-part-id="${renderedPartID(id)}"]`, closest: '[data-timeline-row="AssistantPart"]' } } diff --git a/packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts b/packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts index 7891eba5bd9..e4aa74d12c2 100644 --- a/packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts @@ -6,14 +6,14 @@ import { stopVisualProbe, visualPlan, } from "../../utils/visual-stability" -import { assistantMessage, setupTimeline, textPart, userMessage } from "./fixture" +import { assistantMessage, renderedPartID, setupTimeline, textPart, userMessage } from "./fixture" test("detects blanking caused by ancestor opacity", async ({ page }) => { const partID = "prt_oracle_ancestor_opacity" await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] }) - const row = page.locator(`[data-timeline-part-id="${partID}"]`).first() + const row = page.locator(`[data-timeline-part-id="${renderedPartID(partID)}"]`).first() const regions = defineVisualRegions({ - content: { selector: `[data-timeline-part-id="${partID}"]` }, + content: { selector: `[data-timeline-part-id="${renderedPartID(partID)}"]` }, }) await startVisualProbe(page, regions) await row.evaluate((element) => { @@ -41,13 +41,13 @@ test("detects blanking caused by ancestor opacity", async ({ page }) => { test("detects root opacity when probing descendant opacity", async ({ page }) => { const partID = "prt_oracle_descendant_opacity" await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] }) - const row = page.locator(`[data-timeline-part-id="${partID}"]`).first() + const row = page.locator(`[data-timeline-part-id="${renderedPartID(partID)}"]`).first() await row.evaluate((element) => { element.innerHTML = 'Visible content' }) const regions = defineVisualRegions({ content: { - selector: `[data-timeline-part-id="${partID}"]`, + selector: `[data-timeline-part-id="${renderedPartID(partID)}"]`, opacitySelectors: ['[data-probe-opacity="true"]'], }, }) diff --git a/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts b/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts index e67545104d4..f5bf2d417e1 100644 --- a/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts @@ -9,6 +9,7 @@ import { import { assistantMessage, partUpdated, + renderedPartID, setupTimeline, shell, textPart, @@ -34,8 +35,14 @@ test("does not reverse visible rows when the user wheels during shell remeasurem }) const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) const regions = defineVisualRegions({ - shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + shell: { + selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, + following: { + selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, }) await startVisualProbe(page, regions) await timeline.send(partUpdated(shell(shellID, "running", lines(30))), 80) @@ -143,8 +150,8 @@ test("tracks keyboard scrolling from a focused timeline descendant", async ({ pa reducedMotion: true, }) const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) - const row = page.locator(`[data-timeline-part-id="${shellID}"]`).first() - const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`) + const row = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"]`).first() + const trigger = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"] [data-slot="collapsible-trigger"]`) await row.evaluate((element) => element.setAttribute("tabindex", "0")) await row.focus() for (let index = 0; index < 3; index++) { @@ -182,7 +189,7 @@ test("does not claim keyboard scrolling owned by a nested scrollable", async ({ seedHistory: true, }) const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) - const nested = page.locator(`[data-timeline-part-id="${shellID}"] [data-scrollable]`) + const nested = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"] [data-scrollable]`) await nested.evaluate((element) => (element.scrollTop = element.scrollHeight)) await nested.focus() await page.waitForFunction(() => { @@ -209,7 +216,7 @@ test("does not claim keyboard scrolling owned by a nested scrollable", async ({ await nested.press("PageUp") await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(boundaryBefore) - const nonOverflowing = page.locator(`[data-timeline-part-id="${shellID}"]`).first() + const nonOverflowing = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"]`).first() await nonOverflowing.evaluate((element) => { element.setAttribute("data-scrollable", "") element.setAttribute("tabindex", "0") @@ -238,12 +245,18 @@ test("jump to latest lands on stable final rows after offscreen growth", async ( ) await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 300) const regions = defineVisualRegions({ - shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + shell: { + selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, + following: { + selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, }) await startVisualProbe(page, regions) await page.getByRole("button", { name: /Jump to latest/i }).click() - await expect(page.locator(`[data-timeline-part-id="${followingID}"]`)).toBeVisible() + await expect(page.locator(`[data-timeline-part-id="${renderedPartID(followingID)}"]`)).toBeVisible() await page.waitForTimeout(600) const trace = await stopVisualProbe(page) await reportVisualStability( @@ -277,8 +290,14 @@ test("handles a single row taller than the viewport", async ({ page }, testInfo) seedHistory: true, }) const regions = defineVisualRegions({ - shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + shell: { + selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, + following: { + selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, }) await startVisualProbe(page, regions) await timeline.send(partUpdated(shell(shellID, "completed", lines(100))), 700) diff --git a/packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts index bab1ca23b4f..4964f405343 100644 --- a/packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts @@ -9,6 +9,7 @@ import { import { assistantMessage, partUpdated, + renderedPartID, setupTimeline, shell, textPart, @@ -68,13 +69,16 @@ for (const profile of profiles) { const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight)) await waitForVisualSettle(page, [ - `[data-timeline-part-id="${shellID}"]`, - `[data-timeline-part-id="${followingID}"]`, + `[data-timeline-part-id="${renderedPartID(shellID)}"]`, + `[data-timeline-part-id="${renderedPartID(followingID)}"]`, ]) const regions = defineVisualRegions({ - shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + shell: { + selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, following: { - selector: `[data-timeline-part-id="${followingID}"]`, + selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`, closest: '[data-timeline-row="AssistantPart"]', }, }) @@ -120,10 +124,19 @@ test("keeps following row stable when a collapsed shell receives 50 lines", asyn cpuRate: 4, seedHistory: true, }) - await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`]) + await waitForVisualSettle(page, [ + `[data-timeline-part-id="${renderedPartID(shellID)}"]`, + `[data-timeline-part-id="${renderedPartID(followingID)}"]`, + ]) const regions = defineVisualRegions({ - shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + shell: { + selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, + following: { + selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, }) await startVisualProbe(page, regions) await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 240) @@ -164,10 +177,19 @@ test("keeps rows stable when a running shell becomes an error", async ({ page }, cpuRate: 4, seedHistory: true, }) - await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`]) + await waitForVisualSettle(page, [ + `[data-timeline-part-id="${renderedPartID(shellID)}"]`, + `[data-timeline-part-id="${renderedPartID(followingID)}"]`, + ]) const regions = defineVisualRegions({ - shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + shell: { + selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, + following: { + selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, }) await startVisualProbe(page, regions) await timeline.send( @@ -215,16 +237,20 @@ test("keeps rows stable when later text arrives before shell output", async ({ p cpuRate: 4, seedHistory: true, }) - await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`]) + const following = textPart(followingID, "Later assistant content arrived before shell output.") + await waitForVisualSettle(page, [`[data-timeline-part-id="${renderedPartID(shellID)}"]`]) const regions = defineVisualRegions({ - shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + shell: { + selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, following: { - selector: `[data-timeline-part-id="${followingID}"]`, + selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`, closest: '[data-timeline-row="AssistantPart"]', }, }) await startVisualProbe(page, regions) - await timeline.send(partUpdated(textPart(followingID, "Later assistant content arrived before shell output.")), 240) + await timeline.send(partUpdated(following), 240) await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 300) await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 600) const trace = await stopVisualProbe(page) diff --git a/packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts b/packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts index 03690ec9214..43524776140 100644 --- a/packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts @@ -11,8 +11,8 @@ import { partUpdated, session, sessionID, + renderedPartID, setupTimeline, - textPart, toolPart, userMessage, } from "./fixture" @@ -27,7 +27,7 @@ test("adds a task child-session link without replacing the task row", async ({ p cpuRate: 4, }) const regions = defineVisualRegions({ - task: { selector: `[data-timeline-part-id="${taskID}"] [data-slot="collapsible-trigger"]` }, + task: { selector: `[data-timeline-part-id="${renderedPartID(taskID)}"] [data-slot="collapsible-trigger"]` }, }) await startVisualProbe(page, regions) await timeline.send( @@ -53,54 +53,3 @@ test("adds a task child-session link without replacing the task row", async ({ p page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }), ).toBeVisible() }) - -test("changes generic tool arguments without replacing the row", async ({ page }, testInfo) => { - const toolID = "prt_generic_mutation" - const followingID = "prt_generic_mutation_following" - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage( - [ - toolPart(toolID, "mcp_probe", "running", { target: "one", count: 1 }), - textPart(followingID, "Following generic tool"), - ], - { completed: false }, - ), - ], - cpuRate: 4, - }) - const regions = defineVisualRegions({ - tool: { selector: `[data-timeline-part-id="${toolID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - }) - await startVisualProbe(page, regions) - await timeline.send( - partUpdated(toolPart(toolID, "mcp_probe", "running", { target: "two", count: 2, mode: "deep" })), - 200, - ) - await timeline.send( - partUpdated(toolPart(toolID, "mcp_probe", "completed", { target: "two", count: 2, mode: "deep" })), - 400, - ) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "generic-mutation", - trace, - visualPlan( - regions, - [ - { type: "required", regions: ["tool", "following"] }, - { type: "unique", regions: ["tool", "following"] }, - { type: "stable", regions: ["tool", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 0 }, - { type: "label-stability", regions: "all" }, - { type: "flow", regions: ["tool", "following"] }, - ], - { perMarker: true }, - ), - ) -}) diff --git a/packages/app/e2e/performance/timeline-stability/tools.spec.ts b/packages/app/e2e/performance/timeline-stability/tools.spec.ts index d28fdaa65f5..a3ab1634556 100644 --- a/packages/app/e2e/performance/timeline-stability/tools.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/tools.spec.ts @@ -12,6 +12,7 @@ import { partUpdated, session, sessionID, + renderedPartID, setupTimeline, status, textPart, @@ -48,8 +49,8 @@ test.describe("timeline tool state stability", () => { }) await timeline.send(status("busy"), 120) for (const id of ids) await timeline.waitForPart(`prt_state_${id}`) - await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0) - await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0) + await expect(page.locator(`[data-timeline-part-id="${renderedPartID(questionID)}"]`)).toHaveCount(0) + await expect(page.locator(`[data-timeline-part-id="${renderedPartID(todoID)}"]`)).toHaveCount(0) const regionIDs = [ "prt_state_webfetch", @@ -104,8 +105,10 @@ test.describe("timeline tool state stability", () => { { type: "label-stability", regions: "all" }, ]), ) - await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable") - await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0) + await expect(page.locator(`[data-timeline-part-id="${renderedPartID(questionID)}"]`)).toContainText( + "Keep it stable", + ) + await expect(page.locator(`[data-timeline-part-id="${renderedPartID(todoID)}"]`)).toHaveCount(0) await expect( page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }), ).toBeVisible() @@ -145,7 +148,7 @@ test.describe("timeline tool state stability", () => { }, context: { selector: groupSelector, closest: '[data-timeline-row="AssistantPart"]' }, following: { - selector: '[data-timeline-part-id="prt_ctx_following"]', + selector: `[data-timeline-part-id="${renderedPartID("prt_ctx_following")}"]`, closest: '[data-timeline-row="AssistantPart"]', }, }) @@ -194,5 +197,5 @@ function questionInput() { } function toolRegion(id: string) { - return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' } + return { selector: `[data-timeline-part-id="${renderedPartID(id)}"]`, closest: '[data-timeline-row="AssistantPart"]' } } diff --git a/packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts index e999e8c50a6..ebc616696e3 100644 --- a/packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts @@ -7,13 +7,13 @@ import { visualPlan, } from "../../utils/visual-stability" import { - assistantID, assistantMessage, completedAssistantInfo, event, messageUpdated, partDelta, partUpdated, + renderedPartID, setupTimeline, shell, status, @@ -22,35 +22,6 @@ import { userMessage, } from "./fixture" -test("keeps unchanged siblings stable while a middle part is inserted and removed", async ({ page }, testInfo) => { - const firstID = "prt_mutation_01_first" - const middleID = "prt_mutation_02_middle" - const lastID = "prt_mutation_03_last" - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([textPart(firstID, "First stable row"), textPart(lastID, "Last stable row")], { - completed: false, - }), - ], - cpuRate: 4, - }) - const regions = defineVisualRegions({ - first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - }) - await startVisualProbe(page, regions) - await timeline.send(partUpdated(textPart(middleID, "Inserted middle row. ".repeat(12))), 350) - await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toBeVisible() - await timeline.send( - event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: middleID }), - 500, - ) - await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toHaveCount(0) - const trace = await stopVisualProbe(page) - await reportVisualStability(testInfo, "middle-insert-remove", trace, stablePairPlan(regions, 1)) -}) - test("streams text through growth, canonical replacement, and completion", async ({ page }, testInfo) => { const textID = "prt_text_reconcile" const followingID = "prt_text_reconcile_following" @@ -59,14 +30,20 @@ test("streams text through growth, canonical replacement, and completion", async }) const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 }) const regions = defineVisualRegions({ - text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + text: { + selector: `[data-timeline-part-id="${renderedPartID(textID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, + following: { + selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, }) await startVisualProbe(page, regions) await timeline.send(partDelta(textID, " streamed content"), 100) await timeline.send(partDelta(textID, "\n\n- item one\n- item two\n- item three"), 180) await timeline.send(partUpdated(textPart(textID, "Canonical replacement with a shorter final paragraph.")), 200) - await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 500) + await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 500) const trace = await stopVisualProbe(page) await reportVisualStability( testInfo, @@ -105,17 +82,23 @@ test("inserts a completed question between stable rows", async ({ page }, testIn ], cpuRate: 4, }) - await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0) + await expect(page.locator(`[data-timeline-part-id="${renderedPartID(questionID)}"]`)).toHaveCount(0) const regions = defineVisualRegions({ - first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + first: { + selector: `[data-timeline-part-id="${renderedPartID(firstID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, + last: { + selector: `[data-timeline-part-id="${renderedPartID(lastID)}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, }) await startVisualProbe(page, regions) await timeline.send( partUpdated(toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Yes"]] } })), 600, ) - await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toBeVisible() + await expect(page.locator(`[data-timeline-part-id="${renderedPartID(questionID)}"]`)).toBeVisible() const trace = await stopVisualProbe(page) await reportVisualStability(testInfo, "question-insert", trace, stablePairPlan(regions, 0)) }) @@ -132,8 +115,8 @@ test("replaces thinking with an assistant error without a blank turn", async ({ await startVisualProbe(page, regions) await timeline.send( messageUpdated({ - ...assistant.info, - error: { name: "APIError", data: { message: "Provider failed visibly", isRetryable: false } }, + ...assistant, + error: { type: "APIError", message: "Provider failed visibly" }, }), 500, ) @@ -203,59 +186,6 @@ test("updates retry attempts and long provider messages without remounting the r ) }) -test("reducer-hardening: removes a historical turn one message at a time without moving a visible lower anchor twice", async ({ - page, -}, testInfo) => { - const removeUserID = "msg_0500_remove_user" - const removeAssistantID = "msg_0501_remove_assistant" - const anchorUserID = "msg_2000_anchor_user" - const timeline = await setupTimeline(page, { - messages: [ - userMessage(undefined, { id: removeUserID, created: 1690000000000 }), - assistantMessage([textPart("prt_remove_text", "Removed historical content. ".repeat(15))], { - id: removeAssistantID, - parentID: removeUserID, - created: 1690000001000, - }), - userMessage(undefined, { id: anchorUserID, created: 1700000000000 }), - assistantMessage([textPart("prt_anchor_text", "Visible anchor response")], { - id: "msg_2001_anchor_assistant", - parentID: anchorUserID, - created: 1700000001000, - }), - ], - cpuRate: 4, - }) - const regions = defineVisualRegions({ - anchor: { selector: `[data-timeline-row="UserMessage"][data-message-id="${anchorUserID}"]` }, - }) - await startVisualProbe(page, regions) - await timeline.send( - event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeAssistantID }), - 200, - ) - await timeline.send(event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeUserID }), 500) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "historical-turn-remove", - trace, - visualPlan( - regions, - [ - { type: "required", regions: ["anchor"] }, - { type: "unique", regions: ["anchor"] }, - { type: "stable", regions: ["anchor"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 0 }, - { type: "label-stability", regions: "all" }, - ], - { perMarker: true }, - ), - ) -}) - function stablePairPlan( regions: Record<"first" | "last", { selector: string; closest?: string }>, maxPositionReversals: number, diff --git a/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts b/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts index 838af17c939..e7481a17861 100644 --- a/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts +++ b/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts @@ -1,3 +1,4 @@ +import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise" import type { Page } from "@playwright/test" import { expectSessionTitle } from "../../utils/waits" import { mockOpenCodeServer } from "../../utils/mock-server" @@ -11,42 +12,35 @@ type ParentHydrationBenchmarkMode = "natural" | "candidate" const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural" if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`) const userID = "msg_parent_hydration_user" +const userSeed = fixture.messages[fixture.targetID][0] as SessionMessageUser const user = { - ...fixture.messages[fixture.targetID][0]!, - info: { ...fixture.messages[fixture.targetID][0]!.info, id: userID, time: { created: 1700001000000 } }, - parts: fixture.messages[fixture.targetID][0]!.parts.map((part, index) => ({ - ...part, - id: `prt_parent_hydration_user_${index}`, - messageID: userID, - })), -} -const assistantSeed = fixture.messages[fixture.targetID][3]! + ...userSeed, + id: userID, + time: { created: 1700001000000 }, +} satisfies SessionMessageInfo +const assistantSeed = fixture.messages[fixture.targetID][3] as SessionMessageAssistant const assistants = Array.from({ length: 14 }, (_, index) => { const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}` return { ...assistantSeed, - info: { - ...assistantSeed.info, - id: messageID, - parentID: userID, - time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 }, - }, - parts: assistantSeed.parts.map((part, partIndex) => ({ - ...part, - id: `prt_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}`, - messageID, - })), - } + id: messageID, + time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 }, + content: assistantSeed.content.map((part, partIndex) => + part.type === "tool" + ? { ...part, id: `call_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}` } + : part, + ), + } satisfies SessionMessageInfo }) const messages = [user, ...assistants] const target = fixture.sessions.find((session) => session.id === fixture.targetID)! const lastID = userID const lastAssistant = assistants.at(-1)! -const lastPart = lastAssistant.parts.at(-1)! +const lastPart = lastAssistant.content.at(-1)! const lastPartID = lastPart.type === "tool" ? lastPart.id - : `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}` + : `${lastAssistant.id}:${lastPart.type}:${lastAssistant.content.filter((part) => part.type === lastPart.type).length - 1}` benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => { benchmark.setTimeout(180_000) @@ -107,30 +101,25 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) { }, pageMessages: (sessionID, limit, before) => { const items = sessionID === fixture.targetID ? messages : fixture.messages[fixture.sourceID] - const end = before ? items.findIndex((message) => message.info.id === before) : items.length + const end = before ? items.findIndex((message) => message.id === before) : items.length const start = Math.max(0, end - limit) - return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined } + return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.id : undefined } }, }) - await page.route(`**/session/${fixture.targetID}`, (route) => { - const current = new URL(route.request().url()).pathname.startsWith("/api/") - return route.fulfill({ + await page.route(`**/api/session/${fixture.targetID}`, (route) => + route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify( - current - ? { - data: { - ...target, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - location: { directory: target.directory }, - }, - } - : target, - ), - }) - }) + body: JSON.stringify({ + data: { + ...target, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + location: { directory: target.directory }, + }, + }), + }), + ) await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] }) await page.goto(stressSessionHref(fixture.sourceID)) await expectSessionTitle(page, fixture.expected.sourceTitle) @@ -148,8 +137,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) { { href, title: target.title }, ) const metrics = await measureSessionSwitch(page, { - destinationIDs: messages.map((message) => message.info.id), - sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.info.id), + destinationIDs: messages.map((message) => message.id), + sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.id), lastID, requiredPartID: lastPartID, requireBottomAnchor: false, diff --git a/packages/app/e2e/performance/timeline/session-tab-flash.spec.ts b/packages/app/e2e/performance/timeline/session-tab-flash.spec.ts index 051b29d0cd9..9e9857a7972 100644 --- a/packages/app/e2e/performance/timeline/session-tab-flash.spec.ts +++ b/packages/app/e2e/performance/timeline/session-tab-flash.spec.ts @@ -32,8 +32,8 @@ benchmark("samples cached session repaint after the click", async ({ page, repor await installCachedRepaintProbe(page, { targetHref: stressSessionHref(fixture.targetID), - destination: fixture.messages[fixture.targetID].map((message) => message.info.id), - source: fixture.messages[fixture.sourceID].map((message) => message.info.id), + destination: fixture.messages[fixture.targetID].map((message) => message.id), + source: fixture.messages[fixture.sourceID].map((message) => message.id), last: fixture.expected.targetMessageIDs.at(-1)!, windowMs: 1_000, }) diff --git a/packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts b/packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts index b43832b5f91..eff2c4a1d96 100644 --- a/packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts +++ b/packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts @@ -60,8 +60,8 @@ async function trial(page: Page, mode: "cold" | "hot", reviewPane: "closed" | "o await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!) } - const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.info.id) - const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.info.id) + const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.id) + const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.id) const lastID = fixture.expected.targetMessageIDs.at(-1)! const href = stressSessionHref(fixture.targetID) const result = await measureSessionSwitch(page, { diff --git a/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts b/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts index d3e2b0b0c56..49a34a2a69c 100644 --- a/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts +++ b/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts @@ -1,4 +1,5 @@ import { base64Encode } from "@opencode-ai/core/util/encode" +import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise" import type { Page } from "@playwright/test" import { mockOpenCodeServer } from "../../utils/mock-server" import { expectAppVisible, expectSessionTitle } from "../../utils/waits" @@ -10,37 +11,20 @@ const sessionID = "ses_timeline_state_regression" const userMessageID = "msg_user_regression" const assistantMessageID = "msg_assistant_regression" const editPartID = "prt_0001_edit" -export const textPartID = "prt_9999_text" +export const textPartID = `${assistantMessageID}:text:0` const title = "Timeline collapse state regression" const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } -type EventPayload = { - directory: string - payload: Record -} +type EventPayload = OpenCodeEvent const userMessage = { - info: { - id: userMessageID, - sessionID, - role: "user", - time: { created: 1700000000000 }, - summary: { diffs: [] }, - agent: "build", - model, - }, - parts: [ - { - id: "prt_user_text", - sessionID, - messageID: userMessageID, - type: "text", - text: "Please edit the file.", - }, - ], -} + id: userMessageID, + type: "user", + time: { created: 1700000000000 }, + text: "Please edit the file.", +} satisfies SessionMessageInfo -const editPart = { +const editPart: ToolSeed = { id: editPartID, sessionID, messageID: assistantMessageID, @@ -66,32 +50,16 @@ const editPart = { }, } -const streamedTextPart = { - id: textPartID, - sessionID, - messageID: assistantMessageID, - type: "text", - text: "Streaming added a later assistant text part.", -} - const assistantMessage = { - info: { - id: assistantMessageID, - sessionID, - role: "assistant", - time: { created: 1700000001000 }, - parentID: userMessageID, - modelID: model.modelID, - providerID: model.providerID, - mode: "build", - agent: "build", - path: { cwd: directory, root: directory }, - cost: 0.01, - tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, - variant: "max", - }, - parts: [editPart], -} + id: assistantMessageID, + type: "assistant", + time: { created: 1700000001000 }, + model: { id: model.modelID, providerID: model.providerID, variant: model.variant }, + agent: "build", + cost: 0.01, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + content: [toolContent(editPart)], +} satisfies SessionMessageInfo export async function setupTimelineBenchmark( page: Page, @@ -105,7 +73,7 @@ export async function setupTimelineBenchmark( const events: EventPayload[] = [] let eventBatch = options.eventBatch const currentUserMessage = options.turnDiffs - ? { ...userMessage, info: { ...userMessage.info, summary: { diffs: options.turnDiffs } } } + ? { ...userMessage, metadata: { diffs: options.turnDiffs as JsonValue } } : userMessage await mockOpenCodeServer(page, { directory, @@ -138,7 +106,8 @@ export async function setupTimelineBenchmark( await page.setViewportSize({ width: 1366, height: 768 }) const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) const text = page.locator(`[data-timeline-part-id="${textPartID}"]`).first() - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) await expectSessionTitle(page, title) await expectAppVisible(scroller) return { @@ -182,34 +151,27 @@ export async function setupTimelineBenchmark( } } -export function buildInitialStreamEvent(deltaCount: number): EventPayload { - return { - directory, - payload: { - type: "message.part.updated", - properties: { - part: { - ...streamedTextPart, - text: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``, - }, - }, - }, - } +export function buildInitialStreamEvent(deltaCount: number): EventPayload[] { + return [ + timelineEvent("session.text.started", { sessionID, assistantMessageID, ordinal: 0 }, true), + timelineEvent("session.text.delta", { + sessionID, + assistantMessageID, + ordinal: 0, + delta: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``, + }), + ] } export function buildStreamDeltaEvents(deltaCount: number): EventPayload[] { - return Array.from({ length: deltaCount }, (_, index) => ({ - directory, - payload: { - type: "message.part.delta", - properties: { - messageID: assistantMessageID, - partID: textPartID, - field: "text", - delta: streamChunk(index + 1, deltaCount + 1), - }, - }, - })) + return Array.from({ length: deltaCount }, (_, index) => + timelineEvent("session.text.delta", { + sessionID, + assistantMessageID, + ordinal: 0, + delta: streamChunk(index + 1, deltaCount + 1), + }), + ) } function performanceTurn(index: number) { @@ -315,48 +277,92 @@ function performanceTurn(index: number) { }, ] : []), - ] + ] as unknown as ContentSeed[] return [ { - info: { - id: userID, - sessionID, - role: "user", - time: { created: 1690000000000 + index * 2_000 }, - summary: { diffs: [] }, - agent: "build", - model, - }, - parts: [ - { - id: `prt_0000_${suffix}_user`, - sessionID, - messageID: userID, - type: "text", - text: `Historical prompt ${index}`, - }, - ], + id: userID, + type: "user", + time: { created: 1690000000000 + index * 2_000 }, + text: `Historical prompt ${index}`, }, { - info: { - id: assistantID, - sessionID, - role: "assistant", - time: { created: 1690000001000 + index * 2_000, completed: 1690000001500 + index * 2_000 }, - parentID: userID, - modelID: model.modelID, - providerID: model.providerID, - mode: "build", - agent: "build", - path: { cwd: directory, root: directory }, - cost: 0.01, - tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, - variant: "max", - finish: "stop", - }, - parts, + id: assistantID, + type: "assistant", + time: { created: 1690000001000 + index * 2_000, completed: 1690000001500 + index * 2_000 }, + model: { id: model.modelID, providerID: model.providerID, variant: model.variant }, + agent: "build", + cost: 0.01, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "stop", + content: parts.map((part) => { + if (part.type === "text") return { type: "text" as const, text: part.text } + if (part.type === "reasoning") + return { + type: "reasoning" as const, + text: part.text, + time: { created: part.time.start, completed: part.time.end }, + } + return toolContent(part) + }), }, - ] + ] satisfies SessionMessageInfo[] +} + +type ToolSeed = { + id?: string + sessionID?: string + messageID?: string + type: "tool" + callID: string + tool: string + state: { + status: string + input: Record + output: string + title?: string + metadata: Record + time: { start: number; end: number } + } +} + +type ContentSeedBase = { id?: string; sessionID?: string; messageID?: string } + +type ContentSeed = + | (ContentSeedBase & { type: "text"; text: string }) + | (ContentSeedBase & { type: "reasoning"; text: string; time: { start: number; end: number } }) + | ToolSeed + +function toolContent(part: ToolSeed): SessionMessageAssistant["content"][number] { + return { + type: "tool", + id: part.callID, + name: part.tool, + time: { created: part.state.time.start, ran: part.state.time.start, completed: part.state.time.end }, + state: { + status: "completed", + input: part.state.input as Record, + content: [{ type: "text", text: part.state.output }], + metadata: part.state.metadata as Record, + }, + } +} + +let eventSequence = 0 + +function timelineEvent( + type: Type, + data: Extract["data"], + durable = false, +): Extract { + eventSequence++ + return { + id: `evt_timeline_benchmark_${eventSequence}`, + created: 1700000002000 + eventSequence, + type, + data, + location: { directory }, + ...(durable ? { durable: { aggregateID: sessionID, seq: eventSequence, version: 1 } } : {}), + } as unknown as Extract } function historicalMarkdown(index: number) { diff --git a/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts b/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts index 0c9fc177382..52c364ae58e 100644 --- a/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts +++ b/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts @@ -28,9 +28,22 @@ const directory = "C:/OpenCode/SmokeProject" const projectID = "proj_smoke_timeline" const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } -type MessageInfo = Record & { id: string; role: "user" | "assistant" } -type MessagePart = Record & { id: string; type: string; text?: string; tool?: string } -type Message = { info: MessageInfo; parts: MessagePart[] } +type MessagePart = { + id: string + type: "text" | "reasoning" | "tool" + text?: string + time?: { start: number; end?: number } + callID?: string + tool?: string + state?: { + status: "completed" + input: Record + output: string + title: unknown + metadata: Record + time: { start: number; end: number } + } +} function lorem(seed: number, length: number) { let out = "" @@ -48,54 +61,59 @@ function id(prefix: string, value: number) { return `${prefix}_smoke_${String(value).padStart(4, "0")}` } -function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message { +function userMessage(_sessionID: string, index: number, textLength: number, diffs: unknown[] = []): SessionMessageInfo { const messageID = id("msg_user", index) return { - info: { - id: messageID, - sessionID, - role: "user", - time: { created: 1700000000000 + index * 10_000 }, - summary: { diffs }, - agent: "build", - model, - }, - parts: [ - { - id: id("prt_user_text", index), - sessionID, - messageID, - type: "text", - text: lorem(index, textLength), - }, - ], + id: messageID, + type: "user", + time: { created: 1700000000000 + index * 10_000 }, + text: lorem(index, textLength), + metadata: diffs.length ? { diffs: diffs as JsonValue } : undefined, } } -function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message { +function assistantMessage( + _sessionID: string, + index: number, + _parentID: string, + parts: MessagePart[], +): SessionMessageInfo { const messageID = id("msg_assistant", index) return { - info: { - id: messageID, - sessionID, - role: "assistant", - time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 }, - parentID, - modelID: model.modelID, - providerID: model.providerID, - mode: "build", - agent: "build", - path: { cwd: directory, root: directory }, - cost: 0.01, - tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, - variant: "max", - finish: "stop", + id: messageID, + type: "assistant", + time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 }, + model: { id: model.modelID, providerID: model.providerID, variant: model.variant }, + agent: "build", + cost: 0.01, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "stop", + content: parts.map(messageContent), + } +} + +function messageContent(part: MessagePart): SessionMessageAssistant["content"][number] { + if (part.type === "text") return { type: "text", text: part.text ?? "" } + if (part.type === "reasoning") + return { + type: "reasoning", + text: part.text ?? "", + time: part.time + ? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) } + : undefined, + } + const state = part.state! + return { + type: "tool", + id: part.callID ?? part.id, + name: part.tool!, + time: { created: state.time.start, ran: state.time.start, completed: state.time.end }, + state: { + status: "completed", + input: state.input as Record, + content: [{ type: "text", text: state.output }], + metadata: state.metadata as Record, }, - parts: parts.map((part) => ({ - ...part, - sessionID, - messageID, - })), } } @@ -200,7 +218,7 @@ function code(seed: number, lines: number, width = 32) { ).join("\n") } -function turn(index: number): Message[] { +function turn(index: number): SessionMessageInfo[] { const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : [] const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff) const parts = [ @@ -241,7 +259,7 @@ function turn(index: number): Message[] { ? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)] : []), ] - return [user, assistantMessage(targetID, index, user.info.id, parts)] + return [user, assistantMessage(targetID, index, user.id, parts)] } const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat() @@ -267,19 +285,12 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [ userMessage(childID, index + 2000, 120), assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]), ]).flat() -const messages: Record = { +const messages: Record = { [sourceID]: sourceMessages, [targetID]: targetMessages, [childID]: childMessages, } -function renderable(part: MessagePart) { - if (part.type === "tool" && part.tool === "todowrite") return false - if (part.type === "text") return !!part.text?.trim() - if (part.type === "reasoning") return !!part.text?.trim() - return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch" -} - export const fixture = { directory, project: { @@ -339,14 +350,19 @@ export const fixture = { sourceTitle: "Uncommitted changes inquiry", targetTitle: "Example Game: sample jump movement & sample physics analysis", childTitle: "Inspect child navigation", - sourceMessageIDs: sourceMessages - .filter((message) => message.info.role === "user") - .map((message) => message.info.id), - targetMessageIDs: targetMessages - .filter((message) => message.info.role === "user") - .map((message) => message.info.id), - childMessageIDs: childMessages.filter((message) => message.info.role === "user").map((message) => message.info.id), - targetPartIDs: targetMessages.flatMap((message) => message.parts.filter(renderable).map((part) => part.id)), + sourceMessageIDs: sourceMessages.filter((message) => message.type === "user").map((message) => message.id), + targetMessageIDs: targetMessages.filter((message) => message.type === "user").map((message) => message.id), + childMessageIDs: childMessages.filter((message) => message.type === "user").map((message) => message.id), + targetPartIDs: targetMessages.flatMap((message) => { + if (message.type !== "assistant") return [] + const ordinals = { text: 0, reasoning: 0 } + return message.content.flatMap((part) => { + if (part.type === "text") return part.text.trim() ? [`${message.id}:text:${ordinals.text++}`] : [] + if (part.type === "reasoning") + return part.text.trim() ? [`${message.id}:reasoning:${ordinals.reasoning++}`] : [] + return [part.id] + }) + }), }, } @@ -355,12 +371,13 @@ export function pageMessages(sessionID: string, limit: number, before?: string) const end = before ? Math.max( 0, - messages.findIndex((message) => message.info.id === before), + messages.findIndex((message) => message.id === before), ) : messages.length const start = Math.max(0, end - limit) return { items: messages.slice(start, end), - cursor: start > 0 ? messages[start].info.id : undefined, + cursor: start > 0 ? messages[start].id : undefined, } } +import type { JsonValue, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise" diff --git a/packages/app/e2e/performance/unit/mock-server.test.ts b/packages/app/e2e/performance/unit/mock-server.test.ts index 01c5d29a73a..f1618aafc73 100644 --- a/packages/app/e2e/performance/unit/mock-server.test.ts +++ b/packages/app/e2e/performance/unit/mock-server.test.ts @@ -1,121 +1,6 @@ import { expect, test } from "bun:test" -import type { SessionMessageInfo } from "@opencode-ai/client/promise" import type { Page, Route } from "@playwright/test" -import { currentMessage, mockOpenCodeServer } from "../../utils/mock-server" - -test("preserves current messages", () => { - const message = { - id: "msg_current", - type: "user", - time: { created: 1 }, - text: "current", - files: [{ data: "e30=", mime: "application/json", source: { type: "inline" } }], - } satisfies SessionMessageInfo - - expect(currentMessage(message)).toBe(message) -}) - -test("converts rich legacy messages to current message types", () => { - expect( - currentMessage({ - info: { id: "msg_user", role: "user", time: { created: 1 } }, - parts: [ - { type: "text", text: "Use @src/a.ts with @explore" }, - { - type: "file", - mime: "application/json", - filename: "data.json", - url: "data:application/json;base64,e30=", - }, - { - type: "file", - mime: "text/plain", - filename: "a.ts", - url: "src/a.ts", - source: { type: "file", text: { value: "@src/a.ts", start: 4, end: 13 } }, - }, - { type: "agent", name: "explore", source: { value: "@explore", start: 19, end: 27 } }, - ], - }), - ).toEqual({ - id: "msg_user", - type: "user", - time: { created: 1 }, - text: "Use @src/a.ts with @explore", - files: [ - { data: "e30=", mime: "application/json", name: "data.json", source: { type: "inline" } }, - { - data: "", - mime: "text/plain", - name: "a.ts", - source: { type: "uri", uri: "src/a.ts" }, - mention: { text: "@src/a.ts", start: 4, end: 13 }, - }, - ], - agents: [{ name: "explore", mention: { text: "@explore", start: 19, end: 27 } }], - }) - - expect( - currentMessage({ - info: { - id: "msg_assistant", - role: "assistant", - time: { created: 2, completed: 5 }, - agent: "explore", - modelID: "model", - providerID: "provider", - variant: "high", - cost: 0.5, - tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } }, - finish: "tool-calls", - error: { name: "MessageAbortedError", data: { message: "Stopped" } }, - }, - parts: [ - { type: "text", text: "Answer" }, - { type: "reasoning", text: "Thinking", time: { start: 2, end: 3 } }, - { - id: "prt_tool", - callID: "call_tool", - type: "tool", - tool: "read", - state: { - status: "completed", - input: { filePath: "src/a.ts" }, - output: "contents", - metadata: { title: "a.ts" }, - time: { start: 3, end: 4 }, - }, - }, - ], - }), - ).toEqual({ - id: "msg_assistant", - type: "assistant", - time: { created: 2, completed: 5 }, - agent: "explore", - model: { id: "model", providerID: "provider", variant: "high" }, - cost: 0.5, - tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } }, - finish: "tool-calls", - error: { type: "MessageAbortedError", message: "Stopped" }, - content: [ - { type: "text", text: "Answer" }, - { type: "reasoning", text: "Thinking", time: { created: 2, completed: 3 } }, - { - type: "tool", - id: "call_tool", - name: "read", - time: { created: 3, ran: 3, completed: 4 }, - state: { - status: "completed", - input: { filePath: "src/a.ts" }, - content: [{ type: "text", text: "contents" }], - metadata: { title: "a.ts" }, - }, - }, - ], - }) -}) +import { mockOpenCodeServer } from "../../utils/mock-server" test("applies message latency after a list response gate is released", async () => { const events: string[] = [] diff --git a/packages/app/e2e/performance/unit/visual-stability.test.ts b/packages/app/e2e/performance/unit/visual-stability.test.ts index 2fc3be31666..15656b8dd44 100644 --- a/packages/app/e2e/performance/unit/visual-stability.test.ts +++ b/packages/app/e2e/performance/unit/visual-stability.test.ts @@ -5,7 +5,7 @@ import { type VisualStabilityTrace, } from "../../utils/visual-stability" import { analyzeVisualObservations } from "../../utils/visual-stability/analyzer" -import { legacyVisualPlan, visualPlan, type VisualInvariant } from "../../utils/visual-stability/invariant" +import { visualPlan, type VisualInvariant } from "../../utils/visual-stability/invariant" import { defineVisualRegions, mapVisualRegions } from "../../utils/visual-stability/regions" function trace(samples: VisualStabilityTrace["samples"]): VisualStabilityTrace { @@ -346,19 +346,6 @@ test("evaluates the typed invariant algebra over explicit observations", () => { ]) }) -test("legacy plan adapter preserves analyzer messages and order", () => { - const input = trace([ - frame(0, region({ label: "Exploring", opacity: 1, bottom: 40 }), region({ top: 40, bottom: 60 })), - frame(16, region({ label: "Explored", opacity: 0.2, bottom: 50 }), region({ top: 49, bottom: 69 })), - frame(32, region({ label: "Exploring", opacity: 1, bottom: 50 }), region({ top: 50, bottom: 70 })), - ]) - const options = { flow: ["changing", "following"], stable: ["changing"] } - - expect(analyzeVisualObservations(input.samples, legacyVisualPlan(options))).toEqual( - analyzeVisualStability(input, options), - ) -}) - function frame( at: number, changing: VisualStabilityTrace["samples"][number]["regions"][string], diff --git a/packages/app/e2e/regression/cross-server-tab-close.spec.ts b/packages/app/e2e/regression/cross-server-tab-close.spec.ts index 2bca1f9f521..49faa082d9c 100644 --- a/packages/app/e2e/regression/cross-server-tab-close.spec.ts +++ b/packages/app/e2e/regression/cross-server-tab-close.spec.ts @@ -46,24 +46,6 @@ test("closing the active server's last tab opens the remaining server tab", asyn ).toBe(true) }) -test("legacy session routes preserve an existing tab's server", async ({ page }) => { - await mockServers(page, []) - await page.addInitScript( - ({ serverB, sessionB }) => { - localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) - localStorage.setItem( - "opencode.window.browser.dat:tabs", - JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]), - ) - }, - { serverB, sessionB: sessionB.id }, - ) - - const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` - await page.goto(`/${base64Encode(sessionB.directory)}/session/${sessionB.id}`) - await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) -}) - function session(id: string, directory: string, title: string) { return { id, @@ -91,40 +73,26 @@ async function mockServers(page: Page, requests: string[]) { if (url.pathname === "/api/session/active") return json(route, { data: {} }) if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) }) if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} }) - if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) - if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) - if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) - return json(route, []) - if (url.pathname === "/provider") - return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) - if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) - if (url.pathname === "/project" || url.pathname === "/project/current") { + if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname)) + return json(route, { location: { directory: current.directory }, data: [] }) + if (url.pathname === "/api/model/default") + return json(route, { location: { directory: current.directory }, data: null }) + if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request") + return json(route, { location: { directory: current.directory }, data: [] }) + if (url.pathname === "/api/mcp") return json(route, { location: { directory: current.directory }, data: [] }) + if (url.pathname === "/api/mcp/resource") + return json(route, { location: { directory: current.directory }, data: { resources: [], templates: [] } }) + if (url.pathname === "/api/project" || url.pathname === "/api/project/current") { const project = { id: current.projectID, - worktree: current.directory, + canonical: current.directory, vcs: "git", time: { created: 1, updated: 1 }, sandboxes: [], } - return json(route, url.pathname === "/project" ? [project] : project) + return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory }) } - if (url.pathname === "/path") - return json(route, { - state: current.directory, - config: current.directory, - worktree: current.directory, - directory: current.directory, - home: current.directory, - }) - if (url.pathname === "/api/path") - return json(route, { - state: current.directory, - config: current.directory, - worktree: current.directory, - directory: current.directory, - home: current.directory, - }) - if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (url.pathname === "/api/location") return json(route, { directory: current.directory }) if (url.pathname === "/api/vcs") return json(route, { location: { directory: current.directory }, diff --git a/packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts b/packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts index fa000e1a98b..1dff6fe134f 100644 --- a/packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts +++ b/packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts @@ -6,6 +6,7 @@ import { expectAppVisible } from "../utils/waits" const directory = "C:/OpenCode/PromptInputV2Editing" const projectID = "proj_prompt_input_v2_editing" const sessionID = "ses_prompt_input_v2_editing" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => { await mockOpenCodeServer(page, { @@ -32,7 +33,7 @@ test("preserves the draft when a populated command menu triggers a built-in", as ], pageMessages: () => ({ items: [] }), }) - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) const composer = page.locator('[data-component="prompt-input-v2"]') const input = composer.locator('[data-component="prompt-input"]') await expectAppVisible(composer) diff --git a/packages/app/e2e/regression/prompt-thinking-level.spec.ts b/packages/app/e2e/regression/prompt-thinking-level.spec.ts index 8ec9d68a1e6..1d5b254e84b 100644 --- a/packages/app/e2e/regression/prompt-thinking-level.spec.ts +++ b/packages/app/e2e/regression/prompt-thinking-level.spec.ts @@ -6,6 +6,7 @@ import { expectAppVisible } from "../utils/waits" const directory = "C:/OpenCode/PromptThinkingLevelRegression" const projectID = "proj_prompt_thinking_level_regression" const sessionID = "ses_prompt_thinking_level_regression" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` test("shows the V2 thinking level control while relevant", async ({ page }) => { await mockOpenCodeServer(page, { @@ -49,7 +50,7 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => { ], pageMessages: () => ({ items: [] }), }) - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) const composer = page.locator('[data-component="prompt-input-v2"]') const input = composer.locator('[data-component="prompt-input"]') const control = composer.getByRole("button", { name: "Choose model variant" }) diff --git a/packages/app/e2e/regression/remote-session-settings.spec.ts b/packages/app/e2e/regression/remote-session-settings.spec.ts index f3c18f7102a..a99fa833657 100644 --- a/packages/app/e2e/regression/remote-session-settings.spec.ts +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -49,7 +49,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) => const permissionRequests: string[] = [] const permissionResponses: PermissionResponse[] = [] await installSseTransport(page, { server: serverB }) - const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { + const transport = await installSseTransport(page, { server: serverA, retry: 20, }) @@ -82,18 +82,17 @@ test("auto-accept responds for an unfocused server session", async ({ page }) => await transport.waitForConnection() await transport.send({ - directory: directoryA, - payload: { - id: "event-permission-background-a", - type: "permission.asked", - properties: { - id: "permission-background-a", - sessionID: sessionA.id, - permission: "bash", - patterns: ["git status"], - metadata: {}, - always: [], - }, + id: "evt_permission_background_a", + created: 1700000001000, + type: "permission.asked", + location: { directory: directoryA }, + data: { + id: "permission-background-a", + sessionID: sessionA.id, + action: "bash", + resources: ["git status"], + metadata: {}, + save: [], }, }) @@ -110,18 +109,17 @@ test("auto-accept responds for an unfocused server session", async ({ page }) => ]) await transport.send({ - directory: directoryA, - payload: { - id: "event-permission-background-a-child", - type: "permission.asked", - properties: { - id: "permission-background-a-child", - sessionID: childSessionA.id, - permission: "bash", - patterns: ["git diff"], - metadata: {}, - always: [], - }, + id: "evt_permission_background_a_child", + created: 1700000002000, + type: "permission.asked", + location: { directory: directoryA }, + data: { + id: "permission-background-a-child", + sessionID: childSessionA.id, + action: "bash", + resources: ["git diff"], + metadata: {}, + save: [], }, }) @@ -226,38 +224,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`)) return json(route, { data: [], cursor: {} }) - if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) - if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, []) - if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) - if (url.pathname === "/permission") { - permissionRequests.push(url.toString()) - return json(route, []) - } - if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname)) - return json(route, []) - if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a")) - if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) - if (url.pathname === "/project" || url.pathname === "/project/current") { - const project = { - id: remote ? sessionB.projectID : "project-server-a", - worktree: directory, - vcs: "git", - time: { created: 1, updated: 1 }, - sandboxes: [], - } - return json(route, url.pathname === "/project" ? [project] : project) - } - if (url.pathname === "/path") - return json(route, { - state: directory, - config: directory, - worktree: directory, - directory, - home: directory, - }) - if (url.pathname === "/api/path") - return json(route, { state: directory, config: directory, worktree: directory, directory, home: directory }) - if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (url.pathname === "/api/location") return json(route, { directory }) if (url.pathname === "/api/vcs") return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } }) if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] }) diff --git a/packages/app/e2e/regression/remote-tab-busy.spec.ts b/packages/app/e2e/regression/remote-tab-busy.spec.ts index e614d233796..871bbfca15c 100644 --- a/packages/app/e2e/regression/remote-tab-busy.spec.ts +++ b/packages/app/e2e/regression/remote-tab-busy.spec.ts @@ -57,47 +57,33 @@ async function mockServers(page: Page) { const current = url.origin === serverA ? sessionA : sessionB const directory = url.searchParams.get("directory") if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) - if (url.pathname === "/api/event") return sse(route, url.pathname === "/api/event") + if (url.pathname === "/api/event") return sse(route) if (url.pathname === "/api/health") return json(route, { pid: 1 }) if (url.pathname === "/api/session/active") return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} }) if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) }) if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} }) - if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) - if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) - if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) - return json(route, []) - if (url.pathname === "/provider") - return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) - if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) - if (url.pathname === "/project" || url.pathname === "/project/current") { + if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname)) + return json(route, { location: { directory: current.directory }, data: [] }) + if (url.pathname === "/api/model/default") + return json(route, { location: { directory: current.directory }, data: null }) + if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request") + return json(route, { location: { directory: current.directory }, data: [] }) + if (url.pathname === "/api/mcp") return json(route, { location: { directory: current.directory }, data: [] }) + if (url.pathname === "/api/mcp/resource") + return json(route, { location: { directory: current.directory }, data: { resources: [], templates: [] } }) + if (url.pathname === "/api/project" || url.pathname === "/api/project/current") { const project = { id: current.projectID, - worktree: current.directory, + canonical: current.directory, vcs: "git", time: { created: 1, updated: 1 }, sandboxes: [], } - return json(route, url.pathname === "/project" ? [project] : project) + return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory }) } - if (url.pathname === "/path") - return json(route, { - state: current.directory, - config: current.directory, - worktree: current.directory, - directory: current.directory, - home: current.directory, - }) - if (url.pathname === "/api/path") - return json(route, { - state: current.directory, - config: current.directory, - worktree: current.directory, - directory: current.directory, - home: current.directory, - }) - if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (url.pathname === "/api/location") return json(route, { directory: current.directory }) if (url.pathname === "/api/vcs") return json(route, { location: { directory: current.directory }, @@ -116,10 +102,10 @@ function json(route: Route, body: unknown, status = 200) { }) } -function sse(route: Route, current: boolean) { +function sse(route: Route) { return route.fulfill({ status: 200, contentType: "text/event-stream", - body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n", + body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n', }) } diff --git a/packages/app/e2e/regression/review-image-flash.spec.ts b/packages/app/e2e/regression/review-image-flash.spec.ts index 975992165ab..eafeae24f0e 100644 --- a/packages/app/e2e/regression/review-image-flash.spec.ts +++ b/packages/app/e2e/regression/review-image-flash.spec.ts @@ -7,6 +7,7 @@ const directory = "C:/OpenCode/ReviewImageFlashRegression" const sessionID = "ses_review_image_flash_regression" const title = "Review image flash regression" const imageFile = "assets/preview.png" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` test("clicking an image file in the v2 review pane does not blank the panel", async ({ page }) => { await openReview(page) @@ -109,30 +110,16 @@ async function openReview(page: Page) { pageMessages: () => ({ items: [ { - info: { - id: "msg_review_image_flash_regression", - sessionID, - role: "user", - time: { created: 1700000000000 }, - summary: { diffs: [] }, - agent: "build", - model: { providerID: "opencode", modelID: "test" }, - }, - parts: [ - { - id: "prt_review_image_flash_regression", - sessionID, - messageID: "msg_review_image_flash_regression", - type: "text", - text: "Review this change.", - }, - ], + id: "msg_review_image_flash_regression", + type: "user", + time: { created: 1700000000000 }, + text: "Review this change.", }, ], }), }) - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) await expectSessionTitle(page, title) await page.getByRole("button", { name: "Toggle review" }).click() await expectAppVisible(page.locator('#review-panel [data-component="session-review-v2"]')) diff --git a/packages/app/e2e/regression/review-line-comment.spec.ts b/packages/app/e2e/regression/review-line-comment.spec.ts index 3e50dab65d9..662e1e4a19f 100644 --- a/packages/app/e2e/regression/review-line-comment.spec.ts +++ b/packages/app/e2e/regression/review-line-comment.spec.ts @@ -6,6 +6,7 @@ import { expectAppVisible, expectSessionTitle } from "../utils/waits" const directory = "C:/OpenCode/ReviewLineCommentRegression" const sessionID = "ses_review_line_comment_regression" const title = "Review line comment regression" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` test.beforeEach(async ({ page }) => { await openReview(page) @@ -88,7 +89,6 @@ test("stages a submitted line comment in the prompt context", async ({ page }) = async function openReview(page: Page) { await page.setViewportSize({ width: 700, height: 900 }) await mockOpenCodeServer(page, { - protocol: "v2", directory, project: { id: "proj_review_line_comment_regression", @@ -123,30 +123,16 @@ async function openReview(page: Page) { pageMessages: () => ({ items: [ { - info: { - id: "msg_review_line_comment_regression", - sessionID, - role: "user", - time: { created: 1700000000000 }, - summary: { diffs: [] }, - agent: "build", - model: { providerID: "opencode", modelID: "test" }, - }, - parts: [ - { - id: "prt_review_line_comment_regression", - sessionID, - messageID: "msg_review_line_comment_regression", - type: "text", - text: "Review this change.", - }, - ], + id: "msg_review_line_comment_regression", + type: "user", + time: { created: 1700000000000 }, + text: "Review this change.", }, ], }), }) - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) await expectSessionTitle(page, title) const changes = page.getByRole("tab", { name: "Changes" }) const diffResponse = page.waitForResponse( diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts index d43d9d26c1b..4bd09ea9ce2 100644 --- a/packages/app/e2e/regression/review-open-file.spec.ts +++ b/packages/app/e2e/regression/review-open-file.spec.ts @@ -14,7 +14,6 @@ test.use({ viewport: { width: 1440, height: 900 } }) test("opens and searches project files inline", async ({ page }) => { const searches: { query: string; dirs?: string; limit?: number }[] = [] await mockOpenCodeServer(page, { - protocol: "v2", directory, project: { id: projectID, diff --git a/packages/app/e2e/regression/review-state-persistence.spec.ts b/packages/app/e2e/regression/review-state-persistence.spec.ts index 04e6b0abad2..4d72172d25f 100644 --- a/packages/app/e2e/regression/review-state-persistence.spec.ts +++ b/packages/app/e2e/regression/review-state-persistence.spec.ts @@ -54,7 +54,6 @@ async function switchSession(page: Page, title: string) { async function setup(page: Page) { await mockOpenCodeServer(page, { - protocol: "v2", directory, project: { id: projectID, diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts index cf9098ba91f..0f8b0c78b03 100644 --- a/packages/app/e2e/regression/review-terminal-stacked.spec.ts +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -6,6 +6,7 @@ const directory = "C:/OpenCode/ReviewTerminalStacked" const projectID = "proj_review_terminal_stacked" const sessionID = "ses_review_terminal_stacked" const title = "Review terminal stacked" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` const branchDiffs = [ fileDiff(".github/actions/setup-bun/action.yml", 7), ...Array.from({ length: 2_739 }, (_, index) => @@ -21,7 +22,6 @@ test("keeps the review tree and terminal sized when both panels are open", async test.setTimeout(120_000) await page.setViewportSize({ width: 1400, height: 900 }) await mockOpenCodeServer(page, { - protocol: "v2", directory, project: { id: projectID, @@ -130,8 +130,7 @@ test("keeps the review tree and terminal sized when both panels are open", async ) }) - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) await expectSessionReady(page, { server, sessionID, title }) await expect(page.locator("#review-panel")).toBeVisible() await expectTree(page, 2_773, "action.yml") diff --git a/packages/app/e2e/regression/session-list-path-loading.spec.ts b/packages/app/e2e/regression/session-list-path-loading.spec.ts index 3319514df64..1f1fad0af89 100644 --- a/packages/app/e2e/regression/session-list-path-loading.spec.ts +++ b/packages/app/e2e/regression/session-list-path-loading.spec.ts @@ -16,8 +16,7 @@ test("shows loaded sessions before the directory path request resolves", async ( const pathBlocked = new Promise((resolve) => { releasePath = resolve }) - await page.route("**/api/path?*", async (route) => { - if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback() + await page.route("**/api/location*", async (route) => { await pathBlocked return route.fallback() }) diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index ca02bbf1aa5..63f9e1b27be 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -8,6 +8,7 @@ const directory = "C:/OpenCode/RequestDocks" const projectID = "proj_request_docks" const sessionID = "ses_request_docks" const title = "Request dock regression" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` test("shows a pending question dock", async ({ page }) => { await mockServer(page, { @@ -34,7 +35,7 @@ test("shows a pending question dock", async ({ page }) => { ], }) - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) await expectSessionTitle(page, title) const question = page.locator('[data-component="dock-prompt"][data-kind="question"]') @@ -92,7 +93,7 @@ test("shows a pending permission dock", async ({ page }) => { ], }) - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) await expectSessionTitle(page, title) const permission = page.locator('[data-component="dock-prompt"][data-kind="permission"]') @@ -111,11 +112,11 @@ test("shows a pending permission dock", async ({ page }) => { test("restores the draft caret before typing after a request dock closes", async ({ page }) => { const transport = await installSseTransport(page, { - server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, + server, retry: 20, }) await mockServer(page, { forms: [] }) - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) await transport.waitForConnection() await expectSessionTitle(page, title) @@ -138,26 +139,26 @@ test("restores the draft caret before typing after a request dock closes", async ) .toBe(cursor) await transport.send({ - directory, - payload: { - type: "form.created", - properties: { - form: { - id: "frm_question_caret", - sessionID, - title: "Questions", - metadata: { kind: "question", tool: { messageID: "message-caret", id: "call-caret" } }, - fields: [ - { - key: "q0", - type: "string", - title: "Continue", - description: "Continue?", - options: [{ value: "yes", label: "Yes", description: "Continue the session" }], - custom: true, - }, - ], - }, + id: "evt_form_created", + created: 1700000001000, + type: "form.created", + location: { directory }, + data: { + form: { + id: "frm_question_caret", + sessionID, + title: "Questions", + metadata: { kind: "question", tool: { messageID: "message-caret", id: "call-caret" } }, + fields: [ + { + key: "q0", + type: "string", + title: "Continue", + description: "Continue?", + options: [{ value: "yes", label: "Yes", description: "Continue the session" }], + custom: true, + }, + ], }, }, }) @@ -166,11 +167,11 @@ test("restores the draft caret before typing after a request dock closes", async await expect(editor).toHaveCount(0) await transport.send({ - directory, - payload: { - type: "form.cancelled", - properties: { sessionID, id: "frm_question_caret" }, - }, + id: "evt_form_cancelled", + created: 1700000002000, + type: "form.cancelled", + location: { directory }, + data: { sessionID, id: "frm_question_caret" }, }) await expect(question).toHaveCount(0) await expect(editor).toBeVisible() @@ -183,13 +184,11 @@ async function mockServer( page: Page, requests: { permissions?: unknown[] | (() => unknown[]) - questions?: unknown[] | (() => unknown[]) forms?: unknown[] | (() => unknown[]) sessionStatus?: Record }, ) { await mockOpenCodeServer(page, { - protocol: "v2", directory, project: { id: projectID, @@ -229,7 +228,6 @@ async function mockServer( ], pageMessages: () => ({ items: [] }), permissions: requests.permissions, - questions: requests.questions, forms: requests.forms, sessionStatus: requests.sessionStatus, }) diff --git a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts index 6023467fddb..640cdeba053 100644 --- a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Locator, type Page } from "@playwright/test" +import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible, expectSessionTitle } from "../utils/waits" @@ -8,14 +9,11 @@ const sessionID = "ses_timeline_state_regression" const userMessageID = "msg_user_regression" const assistantMessageID = "msg_assistant_regression" const editPartID = "prt_0001_edit" -const textPartID = "prt_9999_text" +const textPartID = `${assistantMessageID}:text:0` const title = "Timeline collapse state regression" const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } -type EventPayload = { - directory: string - payload: Record -} +type EventPayload = OpenCodeEvent declare global { interface Window { @@ -27,25 +25,11 @@ declare global { } const userMessage = { - info: { - id: userMessageID, - sessionID, - role: "user", - time: { created: 1700000000000 }, - summary: { diffs: [] }, - agent: "build", - model, - }, - parts: [ - { - id: "prt_user_text", - sessionID, - messageID: userMessageID, - type: "text", - text: "Please edit the file.", - }, - ], -} + id: userMessageID, + type: "user", + time: { created: 1700000000000 }, + text: "Please edit the file.", +} satisfies SessionMessageInfo const editPart = { id: editPartID, @@ -74,31 +58,19 @@ const editPart = { } const streamedTextPart = { - id: textPartID, - sessionID, - messageID: assistantMessageID, - type: "text", text: "Streaming added a later assistant text part.", } const assistantMessage = { - info: { - id: assistantMessageID, - sessionID, - role: "assistant", - time: { created: 1700000001000 }, - parentID: userMessageID, - modelID: model.modelID, - providerID: model.providerID, - mode: "build", - agent: "build", - path: { cwd: directory, root: directory }, - cost: 0.01, - tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, - variant: "max", - }, - parts: [editPart], -} + id: assistantMessageID, + type: "assistant", + time: { created: 1700000001000 }, + model: { id: model.modelID, providerID: model.providerID, variant: model.variant }, + agent: "build", + cost: 0.01, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + content: [toolContent(editPart)], +} satisfies SessionMessageInfo test.describe("regression: session timeline local row state", () => { test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => { @@ -106,7 +78,7 @@ test.describe("regression: session timeline local row state", () => { await mockServer(page, events) await configurePage(page) - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await page.goto(sessionHref()) await expectSessionTitle(page, title) const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first() @@ -119,13 +91,7 @@ test.describe("regression: session timeline local row state", () => { await wrapper.locator('[data-slot="collapsible-trigger"]').first().click() await expectExpanded(wrapper, false) - events.push({ - directory, - payload: { - type: "message.part.updated", - properties: { part: streamedTextPart }, - }, - }) + events.push(...textEvents()) await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 }) @@ -136,13 +102,13 @@ test.describe("regression: session timeline local row state", () => { }) }) - test("does not remount an edit diff when sibling parts or diff counts update", async ({ page }) => { + test("does not remount an edit diff when a sibling part arrives", async ({ page }) => { const events: EventPayload[] = [] await installDiffProbe(page) await mockServer(page, events) await configurePage(page) - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await page.goto(sessionHref()) await expectSessionTitle(page, title) const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first() @@ -151,13 +117,7 @@ test.describe("regression: session timeline local row state", () => { await expectAppVisible(file) await markDiffProbe(page) - events.push({ - directory, - payload: { - type: "message.part.updated", - properties: { part: streamedTextPart }, - }, - }) + events.push(...textEvents()) await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 }) const siblingProbe = await readDiffProbe(page) @@ -169,27 +129,6 @@ test.describe("regression: session timeline local row state", () => { shadowRoots: 0, toolMarker: "before", }) - - await markDiffProbe(page) - events.push({ - directory, - payload: { - type: "message.part.updated", - properties: { part: editPartWithAdditions(2) }, - }, - }) - - await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible( - { timeout: 10_000 }, - ) - expect(await readDiffProbe(page)).toEqual({ - fileMarker: "before", - frameMarker: "before", - rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`, - rowMarker: "before", - shadowRoots: 0, - toolMarker: "before", - }) }) test("keeps a sticky edit header aligned with a multi-hunk diff", async ({ page }) => { @@ -216,10 +155,10 @@ test.describe("regression: session timeline local row state", () => { }, }, } - await mockServer(page, events, [userMessage, { ...assistantMessage, parts: [part] }]) + await mockServer(page, events, [userMessage, { ...assistantMessage, content: [toolContent(part)] }]) await configurePage(page) - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await page.goto(sessionHref()) await expectSessionTitle(page, title) const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first() @@ -355,22 +294,103 @@ async function readDiffProbe(page: Page) { }) } -function editPartWithAdditions(additions: number) { +function toolContent(part: typeof editPart): SessionMessageAssistant["content"][number] { return { - ...editPart, + type: "tool", + id: part.callID, + name: part.tool, + time: { created: part.state.time.start, ran: part.state.time.start, completed: part.state.time.end }, state: { - ...editPart.state, - metadata: { - ...editPart.state.metadata, - filediff: { - ...editPart.state.metadata.filediff, - additions, - }, - }, + status: "completed", + input: part.state.input, + content: [{ type: "text", text: part.state.output }], + metadata: part.state.metadata as Record, }, } } +let eventSequence = 0 + +function textEvents(): OpenCodeEvent[] { + return [ + eventValue("session.text.started", { sessionID, assistantMessageID, ordinal: 0 }, 1), + eventValue( + "session.text.ended", + { + sessionID, + assistantMessageID, + ordinal: 0, + text: streamedTextPart.text, + }, + 1, + ), + ] +} + +function toolEvents(part: typeof editPart): OpenCodeEvent[] { + return [ + eventValue( + "session.tool.input.started", + { + sessionID, + assistantMessageID, + id: part.callID, + name: part.tool, + }, + 1, + ), + eventValue( + "session.tool.input.ended", + { + sessionID, + assistantMessageID, + id: part.callID, + text: JSON.stringify(part.state.input), + }, + 1, + ), + eventValue( + "session.tool.called", + { + sessionID, + assistantMessageID, + id: part.callID, + input: part.state.input, + executed: true, + }, + 1, + ), + eventValue( + "session.tool.success", + { + sessionID, + assistantMessageID, + id: part.callID, + content: [{ type: "text", text: part.state.output }], + metadata: part.state.metadata as Record, + executed: true, + }, + 2, + ), + ] +} + +function eventValue( + type: Type, + data: Extract["data"], + version: 1 | 2, +): Extract { + eventSequence++ + return { + id: `evt_collapse_${eventSequence}`, + created: 1700000002000 + eventSequence, + type, + data, + location: { directory }, + durable: { aggregateID: sessionID, seq: eventSequence, version }, + } as unknown as Extract +} + function readExpanded(element: Element) { const trigger = element.querySelector('[data-slot="collapsible-trigger"]') const aria = trigger?.getAttribute("aria-expanded") @@ -385,7 +405,11 @@ function readExpanded(element: Element) { return !!content && content.getBoundingClientRect().height > 0 } -async function mockServer(page: Page, events: EventPayload[], messages = [userMessage, assistantMessage]) { +async function mockServer( + page: Page, + events: EventPayload[], + messages: SessionMessageInfo[] = [userMessage, assistantMessage], +) { await mockOpenCodeServer(page, { directory, project: project(), @@ -437,3 +461,8 @@ function provider() { function base64Encode(value: string) { return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") } + +function sessionHref() { + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + return `/server/${base64Encode(server)}/session/${sessionID}` +} diff --git a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts index a8cac39490f..6bd1f95b9f3 100644 --- a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts +++ b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page } from "@playwright/test" +import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible, expectSessionTitle } from "../utils/waits" import { @@ -17,11 +18,6 @@ const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "ma const contextIDs = ["ctx_0100_read", "ctx_0101_glob", "ctx_0102_grep", "ctx_0103_list"] const followingTextID = `${id("msg_assistant", 10)}:text:0` -type Message = { - info: Record & { id: string; role: "user" | "assistant" } - parts: Record[] -} - const messages = [...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(), ...turn(10, true)] test.describe("regression: session timeline context group resize", () => { @@ -30,7 +26,7 @@ test.describe("regression: session timeline context group resize", () => { await mockServer(page) await configurePage(page) - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await page.goto(sessionHref()) await expectSessionTitle(page, title) await expectAppVisible(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()) await expectAppVisible(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first()) @@ -45,7 +41,7 @@ test.describe("regression: session timeline context group resize", () => { }) test("paints a stable exploring to explored transition", async ({ page }) => { - const events: { directory: string; payload: Record }[] = [] + const events: OpenCodeEvent[] = [] await page.setViewportSize({ width: 1400, height: 900 }) await mockServer(page, events, [ ...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(), @@ -53,7 +49,7 @@ test.describe("regression: session timeline context group resize", () => { ]) await configurePage(page) - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await page.goto(sessionHref()) await expectSessionTitle(page, title) const devtools = await page.context().newCDPSession(page) await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 }) @@ -75,25 +71,21 @@ test.describe("regression: session timeline context group resize", () => { }) await startVisualProbe(page, regions) for (const [index, delay] of [120, 350, 80, 500].entries()) { - events.push({ - directory, - payload: { - type: "message.part.updated", - properties: { - part: contextTool( - contextIDs[index]!, - id("msg_assistant", 10), - ["read", "glob", "grep", "list"][index]!, - [ - { filePath: "src/recent-a.ts" }, - { path: directory, pattern: "**/*.ts" }, - { path: directory, pattern: "Explored" }, - { path: "src" }, - ][index]!, - ), - }, - }, - }) + events.push( + ...toolEvents( + contextTool( + contextIDs[index]!, + id("msg_assistant", 10), + ["read", "glob", "grep", "list"][index]!, + [ + { filePath: "src/recent-a.ts" }, + { path: directory, pattern: "**/*.ts" }, + { path: directory, pattern: "Explored" }, + { path: "src" }, + ][index]!, + ), + ), + ) await page.waitForTimeout(delay) } @@ -211,74 +203,51 @@ async function sampleExpansion(page: Page) { ) } -function turn(index: number, target: boolean, status: "running" | "completed" = "completed"): Message[] { +function turn(index: number, target: boolean, status: "running" | "completed" = "completed"): SessionMessageInfo[] { const userID = id("msg_user", index) const assistantID = id("msg_assistant", index) + const content: SessionMessageAssistant["content"] = target + ? [ + toolContent( + contextTool( + contextIDs[0]!, + assistantID, + "read", + { filePath: "src/recent-a.ts", offset: 0, limit: 120 }, + status, + ), + ), + toolContent(contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status)), + toolContent( + contextTool( + contextIDs[2]!, + assistantID, + "grep", + { path: directory, pattern: "Explored", include: "*.ts" }, + status, + ), + ), + toolContent(contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status)), + { type: "text", text: "This assistant text is immediately after the explored context group." }, + ] + : [{ type: "text", text: `Assistant filler ${index}. ${"filler ".repeat(60)}` }] return [ { - info: { - id: userID, - sessionID, - role: "user", - time: { created: 1700000000000 + index * 10_000 }, - summary: { diffs: [] }, - agent: "build", - model, - }, - parts: [{ id: id("prt_user", index), sessionID, messageID: userID, type: "text", text: `User message ${index}` }], + id: userID, + type: "user", + time: { created: 1700000000000 + index * 10_000 }, + text: `User message ${index}`, }, { - info: { - id: assistantID, - sessionID, - role: "assistant", - time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 2_000 }, - parentID: userID, - modelID: model.modelID, - providerID: model.providerID, - mode: "build", - agent: "build", - path: { cwd: directory, root: directory }, - cost: 0.01, - tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, - variant: "max", - finish: "stop", - }, - parts: target - ? [ - contextTool( - contextIDs[0]!, - assistantID, - "read", - { filePath: "src/recent-a.ts", offset: 0, limit: 120 }, - status, - ), - contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status), - contextTool( - contextIDs[2]!, - assistantID, - "grep", - { path: directory, pattern: "Explored", include: "*.ts" }, - status, - ), - contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status), - { - id: followingTextID, - sessionID, - messageID: assistantID, - type: "text", - text: "This assistant text is immediately after the explored context group.", - }, - ] - : [ - { - id: id("prt_text", index), - sessionID, - messageID: assistantID, - type: "text", - text: `Assistant filler ${index}. ${"filler ".repeat(60)}`, - }, - ], + id: assistantID, + type: "assistant", + time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 2_000 }, + model: { id: model.modelID, providerID: model.providerID, variant: model.variant }, + agent: "build", + cost: 0.01, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "stop", + content, }, ] } @@ -308,11 +277,110 @@ function contextTool( } } -async function mockServer( - page: Page, - events: { directory: string; payload: Record }[] = [], - fixtureMessages = messages, -) { +type ContextTool = ReturnType + +function toolContent(part: ContextTool): SessionMessageAssistant["content"][number] { + const base = { + type: "tool" as const, + id: part.callID, + name: part.tool, + time: { + created: part.state.time.start, + ran: part.state.time.start, + ...(part.state.status === "completed" ? { completed: part.state.time.end } : {}), + }, + } + if (part.state.status === "running") + return { + ...base, + state: { + status: "running", + input: part.state.input as Record, + metadata: part.state.metadata as Record, + }, + } + return { + ...base, + state: { + status: "completed", + input: part.state.input as Record, + content: [{ type: "text", text: part.state.output }], + metadata: part.state.metadata as Record, + }, + } +} + +let eventSequence = 0 + +function toolEvents(part: ContextTool): OpenCodeEvent[] { + const events = [ + eventValue( + "session.tool.input.started", + { + sessionID, + assistantMessageID: part.messageID, + id: part.callID, + name: part.tool, + }, + 1, + ), + eventValue( + "session.tool.input.ended", + { + sessionID, + assistantMessageID: part.messageID, + id: part.callID, + text: JSON.stringify(part.state.input), + }, + 1, + ), + eventValue( + "session.tool.called", + { + sessionID, + assistantMessageID: part.messageID, + id: part.callID, + input: part.state.input, + executed: true, + }, + 1, + ), + ] satisfies OpenCodeEvent[] + if (part.state.status === "running") return events + return [ + ...events, + eventValue( + "session.tool.success", + { + sessionID, + assistantMessageID: part.messageID, + id: part.callID, + content: [{ type: "text", text: part.state.output }], + metadata: part.state.metadata, + executed: true, + }, + 2, + ), + ] +} + +function eventValue( + type: Type, + data: Extract["data"], + version: 1 | 2, +): Extract { + eventSequence++ + return { + id: `evt_context_resize_${eventSequence}`, + created: 1700000002000 + eventSequence, + type, + data, + location: { directory }, + durable: { aggregateID: sessionID, seq: eventSequence, version }, + } as unknown as Extract +} + +async function mockServer(page: Page, events: OpenCodeEvent[] = [], fixtureMessages = messages) { await mockOpenCodeServer(page, { directory, project: project(), @@ -372,3 +440,8 @@ function provider() { function base64Encode(value: string) { return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") } + +function sessionHref() { + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + return `/server/${base64Encode(server)}/session/${sessionID}` +} diff --git a/packages/app/e2e/regression/session-timeline-file-state.spec.ts b/packages/app/e2e/regression/session-timeline-file-state.spec.ts index cb228c13c7a..48d0f70ed26 100644 --- a/packages/app/e2e/regression/session-timeline-file-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-file-state.spec.ts @@ -1,30 +1,5 @@ import { expect, test } from "@playwright/test" -import { - assistantMessage, - partUpdated, - setupTimeline, - toolPart, - userMessage, -} from "../performance/timeline-stability/fixture" - -test("updates edit diagnostics without resetting manual collapse state", async ({ page }) => { - const editID = "prt_diagnostics_edit" - const base = editPart(editID, []) - const timeline = await setupTimeline(page, { - messages: [userMessage(), assistantMessage([base])], - settings: { editToolPartsExpanded: true }, - }) - const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first() - await trigger.click() - await expect(trigger).toHaveAttribute("aria-expanded", "false") - await timeline.send( - partUpdated(editPart(editID, [diagnostic("First failure", 2), diagnostic("Second failure", 4)])), - 300, - ) - await expect(trigger).toHaveAttribute("aria-expanded", "false") - await timeline.send(partUpdated(editPart(editID, [])), 300) - await expect(trigger).toHaveAttribute("aria-expanded", "false") -}) +import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture" test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => { const patchID = "prt_nested_patch" @@ -68,25 +43,6 @@ function patchFile(filePath: string, type: "add" | "update" | "delete") { } } -function editPart(id: string, diagnostics: Record[]) { - return toolPart( - id, - "edit", - "completed", - { filePath: "src/edit.ts" }, - { - metadata: { - filediff: { file: "src/edit.ts", additions: 1, deletions: 1, before: source(false), after: source(true) }, - diagnostics, - }, - }, - ) -} - -function diagnostic(message: string, line: number) { - return { message, severity: 1, range: { start: { line, character: 0 }, end: { line, character: 2 } } } -} - function source(changed: boolean) { return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join( "", diff --git a/packages/app/e2e/regression/session-timeline-history-root.spec.ts b/packages/app/e2e/regression/session-timeline-history-root.spec.ts index f892cb6f3f3..ce6b63a9987 100644 --- a/packages/app/e2e/regression/session-timeline-history-root.spec.ts +++ b/packages/app/e2e/regression/session-timeline-history-root.spec.ts @@ -1,4 +1,5 @@ import { base64Encode } from "@opencode-ai/core/util/encode" +import type { SessionMessageAssistant } from "@opencode-ai/client/promise" import { expect, test, type Page } from "@playwright/test" import { assistantMessage, @@ -17,6 +18,7 @@ import { installSseTransport } from "../utils/sse-transport" import { expectSessionTitle } from "../utils/waits" const messagePageSize = 200 +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => { const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user` return [ @@ -29,19 +31,19 @@ const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => { }), ] }).flat() -const assistants = messages.filter((message) => message.info.role === "assistant") +const assistants = messages.filter((message): message is SessionMessageAssistant => message.type === "assistant") const lastAssistant = assistants.at(-1)! -const lastPartID = `${assistants.at(-1)!.info.id}:text:0` -const userPartID = `${messages.at(-2)!.info.id}:text:0` +const lastPartID = `${assistants.at(-1)!.id}:text:0` +const userPartID = `${messages.at(-2)!.id}:text:0` const completed = { - ...lastAssistant.info, - time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 }, + ...lastAssistant, + time: { ...lastAssistant.time, completed: lastAssistant.time.created + 15_000 }, } const scenarios = [ { name: "completion", info: completed, idleFirst: false, interrupted: false }, { name: "interruption", - info: { ...completed, error: { name: "MessageAbortedError", data: { message: "Stopped" } } }, + info: { ...completed, error: { type: "MessageAbortedError", message: "Stopped" } }, idleFirst: true, interrupted: true, }, @@ -56,12 +58,11 @@ for (const scenario of scenarios) { const roots: { sessionID: string; messageID: string }[] = [] const sequence: string[] = [] const history = Promise.withResolvers() - const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { - server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, + const transport = await installSseTransport(page, { + server, retry: 20, }) await mockOpenCodeServer(page, { - protocol: "v2", directory, project: project(), provider: { @@ -94,15 +95,15 @@ for (const scenario of scenarios) { }, message: (requestedSessionID, messageID) => { if (requestedSessionID !== sessionID) return - return messages.find((item) => item.info.id === messageID) + return messages.find((item) => item.id === messageID) }, pageMessages: (_, limit, before) => { pages.push({ before, limit }) - const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length + const end = before ? messages.findIndex((message) => message.id === before) : messages.length const start = Math.max(0, end - limit) return { items: messages.slice(start, end), - cursor: start > 0 ? messages[start]!.info.id : undefined, + cursor: start > 0 ? messages[start]!.id : undefined, } }, }) @@ -152,7 +153,7 @@ for (const scenario of scenarios) { requestAnimationFrame(() => setTimeout(sample, 0)) }) - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) await transport.waitForConnection() await expectSessionTitle(page, title) await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible() @@ -169,7 +170,7 @@ for (const scenario of scenarios) { expect(sequence.slice(0, 3)).toEqual([ "messages:start:latest", "messages:end:latest", - `messages:start:${messages.at(-messagePageSize)!.info.id}`, + `messages:start:${messages.at(-messagePageSize)!.id}`, ]) await page.evaluate(() => { ;( @@ -187,7 +188,7 @@ for (const scenario of scenarios) { await waitForProbeSamples(page, beforeHistory) expect(pages).toEqual([ { before: undefined, limit: messagePageSize }, - { before: messages.at(-messagePageSize)!.info.id, limit: messagePageSize }, + { before: messages.at(-messagePageSize)!.id, limit: messagePageSize }, ]) expect(roots).toEqual([]) diff --git a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts index 1dbb7af88a2..28cec5b2e30 100644 --- a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts @@ -5,6 +5,7 @@ import { messageUpdated, partUpdated, reasoningPart, + renderedPartID, setupTimeline, shell, status, @@ -65,7 +66,7 @@ test("transitions thinking and hidden reasoning through busy to idle", async ({ 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) - await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100) + await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100) 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) @@ -100,11 +101,13 @@ test("moves busy through retry and recovery to final idle content", async ({ pag await timeline.send(status("busy", 2), 180) await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140) - await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100) + await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100) await timeline.send(status("idle"), 350) await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0) await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) - await expect(page.locator('[data-timeline-part-id="prt_recovered"]')).toContainText("Recovered response") + await expect(page.locator(`[data-timeline-part-id="${renderedPartID("prt_recovered")}"]`)).toContainText( + "Recovered response", + ) }) function lines(count: number) { diff --git a/packages/app/e2e/regression/session-timeline-notices.spec.ts b/packages/app/e2e/regression/session-timeline-notices.spec.ts index 23b55eb7b7d..5f19a23ccd8 100644 --- a/packages/app/e2e/regression/session-timeline-notices.spec.ts +++ b/packages/app/e2e/regression/session-timeline-notices.spec.ts @@ -39,7 +39,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => { ownerWarnings.push(message.text()) }) await setupTimeline(page, { - currentMessages: [ + sessionMessages: [ user, { id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } }, assistant(true), @@ -74,7 +74,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => { }) test("moves blocking work to the background with Ctrl+B", async ({ page }) => { - await setupTimeline(page, { currentMessages: [user, assistant(false, true)] }) + await setupTimeline(page, { sessionMessages: [user, assistant(false, true)] }) const card = page.locator('[data-component="task-tool-card"]') await expect(card).toBeVisible() await expect(card).toContainText("Inspect code") @@ -92,14 +92,14 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => { }) test("waits for completion before labeling requested background work", async ({ page }) => { - await setupTimeline(page, { currentMessages: [user, assistant(false, true, undefined, true)] }) + 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, { - currentMessages: [user, assistant(false, true, childID)], + sessionMessages: [user, assistant(false, true, childID)], sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })], sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } }, }) @@ -113,7 +113,7 @@ test("navigates from a running subagent card and hides background controls in th test("shows a badge for active background work", async ({ page }) => { const childID = "ses_background_child" await setupTimeline(page, { - currentMessages: [user, assistant(true)], + sessionMessages: [user, assistant(true)], sessions: [session(), session({ id: childID, parentID: sessionID })], sessionStatus: { [childID]: { type: "busy" } }, }) @@ -125,7 +125,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({ const backgroundID = "ses_background_existing" const blockingID = "ses_background_blocking" const timeline = await setupTimeline(page, { - currentMessages: [ + sessionMessages: [ user, { id: "msg_backgrounded", diff --git a/packages/app/e2e/regression/session-timeline-projection.spec.ts b/packages/app/e2e/regression/session-timeline-projection.spec.ts index 31549392e1b..2547208c931 100644 --- a/packages/app/e2e/regression/session-timeline-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-projection.spec.ts @@ -71,33 +71,20 @@ test.describe("session timeline projection", () => { test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => { const firstUser = userMessage( [ - userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", { - id: "prt_comment", - synthetic: true, - metadata: { - opencodeComment: { - path: "src/a.ts", - selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 }, - comment: "Keep this stable", - }, - }, - }), + userText("Keep this stable", { id: "prt_comment" }), userText("Continue after the comment", { id: "prt_visible_user" }), ], { summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } }, ) const aborted = assistantMessage([{ id: "prt_before_abort", type: "text", text: "Before interruption" }], { id: "msg_1001_assistant_aborted", - error: { name: "MessageAbortedError", data: { message: "Stopped" } }, + error: { type: "MessageAbortedError", message: "Stopped" }, }) const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], { id: "msg_1002_assistant_failed", error: { - name: "APIError", - data: { - message: JSON.stringify({ error: { type: "provider_error", message: "Visible provider failure" } }), - isRetryable: false, - }, + type: "APIError", + message: "Visible provider failure", }, created: 1700000003000, }) @@ -122,48 +109,11 @@ test.describe("session timeline projection", () => { await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible() }) - test("renders legacy synthetic comments as ordinary V2 user text", async ({ page }) => { - const user = userMessage( - [ - userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", { - id: "prt_comment_only", - synthetic: true, - metadata: { - opencodeComment: { - path: "src/a.ts", - selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 }, - comment: "Keep this stable", - }, - }, - }), - userText("Continue after the comment", { id: "prt_comment_visible" }), - ], - { summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } }, - ) - const nextUser = userMessage(undefined, { id: "msg_2000_diff_next_user", created: 1700000010000 }) - const nextAssistant = assistantMessage([], { - id: "msg_2001_diff_next_assistant", - parentID: "msg_2000_diff_next_user", - created: 1700000011000, - }) - await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] }) - const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) - await scroller.evaluate((element) => (element.scrollTop = 0)) - - await expect( - page.getByText( - "The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable Continue after the comment", - { exact: true }, - ), - ).toBeVisible() - await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0) - }) - 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: { name: "MessageAbortedError", data: { message: "Stopped" } }, + error: { type: "MessageAbortedError", message: "Stopped" }, }) const after = assistantMessage([{ id: "prt_after", type: "text", text: "After" }], { id: "msg_1002_after", diff --git a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts index ad35eef601c..d614a87f941 100644 --- a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts @@ -4,6 +4,7 @@ import { completedAssistantInfo, messageUpdated, partUpdated, + renderedPartID, setupTimeline, shell, status, @@ -36,8 +37,10 @@ test("reducer-hardening: converges when idle arrives before final part and messa 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.info)), 250) + 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="${textID}"]`)).toContainText("Final after early idle") + await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText( + "Final after early idle", + ) }) diff --git a/packages/app/e2e/regression/session-timeline-tool-state.spec.ts b/packages/app/e2e/regression/session-timeline-tool-state.spec.ts deleted file mode 100644 index 63646f4454c..00000000000 --- a/packages/app/e2e/regression/session-timeline-tool-state.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - assistantMessage, - partUpdated, - setupTimeline, - toolPart, - userMessage, -} from "../performance/timeline-stability/fixture" - -test("updates expanded web search links without resetting expansion", async ({ page }) => { - const searchID = "prt_websearch_mutation" - const input = { query: "timeline stability" } - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([toolPart(searchID, "websearch", "completed", input, { output: "https://example.com/one" })]), - ], - }) - const wrapper = page.locator(`[data-timeline-part-id="${searchID}"]`) - const trigger = wrapper.locator('[data-slot="collapsible-trigger"]') - await trigger.click() - await expect(trigger).toHaveAttribute("aria-expanded", "true") - await timeline.send( - partUpdated( - toolPart(searchID, "websearch", "completed", input, { - output: "https://example.com/one\nhttps://example.com/two", - }), - ), - 300, - ) - await expect(trigger).toHaveAttribute("aria-expanded", "true") - await expect(wrapper.locator('a[href="https://example.com/two"]')).toBeVisible() -}) - -test("preserves an expanded tool error card across duplicate delivery", async ({ page }) => { - const toolID = "prt_duplicate_error" - const failed = toolPart(toolID, "bash", "error", { command: "exit 1" }, { error: "Command failed visibly" }) - const timeline = await setupTimeline(page, { messages: [userMessage(), assistantMessage([failed])] }) - const wrapper = page.locator(`[data-timeline-part-id="${toolID}"]`) - const trigger = wrapper.locator('[data-slot="collapsible-trigger"]') - await trigger.click() - await expect(trigger).toHaveAttribute("aria-expanded", "true") - await timeline.send(partUpdated(failed), 150) - await timeline.send(partUpdated(failed), 250) - await expect(trigger).toHaveAttribute("aria-expanded", "true") - await expect(wrapper).toContainText("Command failed visibly") -}) - -test("renders multiple question answers and preserves open state on answer updates", async ({ page }) => { - const questionID = "prt_multi_question" - const input = { - questions: [ - { header: "First", question: "First choice?", options: [] }, - { header: "Second", question: "Second choice?", options: [], multiple: true }, - ], - } - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([ - toolPart(questionID, "question", "completed", input, { metadata: { answers: [["A"], ["B", "C"]] } }), - ]), - ], - }) - const wrapper = page.locator(`[data-timeline-part-id="${questionID}"]`) - const trigger = wrapper.locator('[data-slot="collapsible-trigger"]') - await expect(trigger).toHaveAttribute("aria-expanded", "true") - await timeline.send( - partUpdated( - toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Updated"], ["B", "C"]] } }), - ), - 300, - ) - await expect(trigger).toHaveAttribute("aria-expanded", "true") - await expect(wrapper).toContainText("Updated") - await expect(wrapper).toContainText("B, C") -}) diff --git a/packages/app/e2e/regression/session-timeline-transport.spec.ts b/packages/app/e2e/regression/session-timeline-transport.spec.ts index e99075f1fb1..2ae6eb2eeab 100644 --- a/packages/app/e2e/regression/session-timeline-transport.spec.ts +++ b/packages/app/e2e/regression/session-timeline-transport.spec.ts @@ -1,43 +1,44 @@ import { expect, test, type Page } from "@playwright/test" -import { partUpdated, setupTimeline, textPart } from "../performance/timeline-stability/fixture" +import { partUpdated, renderedPartID, setupTimeline, textPart } from "../performance/timeline-stability/fixture" test("keeps one connection open while delivering multiple events", async ({ page }) => { const timeline = await setupTimeline(page) - const first = await timeline.transport.send(partUpdated(textPart("prt_transport_first", "first event"))) - const second = await timeline.transport.send(partUpdated(textPart("prt_transport_second", "second event"))) + const first = (await timeline.transport.burst(partUpdated(textPart("prt_transport_first", "first event")))).at(-1)! + const second = (await timeline.transport.burst(partUpdated(textPart("prt_transport_second", "second event")))).at(-1)! await timeline.waitForPart("prt_transport_first") await timeline.waitForPart("prt_transport_second") expect(first.connectionID).toBe(second.connectionID) await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) - expect(await timeline.transport.acknowledgements()).toHaveLength(2) + expect(await timeline.transport.acknowledgements()).toHaveLength(4) }) test("delivers a burst from one stream chunk", async ({ page }) => { const timeline = await setupTimeline(page) const acknowledgements = await timeline.transport.burst([ - partUpdated(textPart("prt_transport_burst_a", "burst a")), - partUpdated(textPart("prt_transport_burst_b", "burst b")), + ...partUpdated(textPart("prt_transport_burst_a", "burst a")), + ...partUpdated(textPart("prt_transport_burst_b", "burst b")), ]) await timeline.waitForPart("prt_transport_burst_a") await timeline.waitForPart("prt_transport_burst_b") - expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1]) - expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(2) + expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1, 1, 1]) + expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(4) }) test("parses split JSON and a split multibyte code point", async ({ page }) => { const timeline = await setupTimeline(page) - const payload = partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603")) + const [started, payload] = partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603")) + await timeline.transport.send(started!) const encoded = new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`) const snowman = new TextEncoder().encode("\u2603")[0]! const multibyte = encoded.indexOf(snowman) - const acknowledgement = await timeline.transport.split(payload, [9, multibyte + 1, multibyte + 2]) + const acknowledgement = await timeline.transport.split(payload!, [9, multibyte + 1, multibyte + 2]) await timeline.waitForPart("prt_transport_split") - await expect(page.locator('[data-timeline-part-id="prt_transport_split"]')).toContainText( + await expect(page.locator(`[data-timeline-part-id="${renderedPartID("prt_transport_split")}"]`)).toContainText( "split snowman \u2603\u2603\u2603", ) expect(acknowledgement.chunkCount).toBe(4) @@ -46,12 +47,11 @@ test("parses split JSON and a split multibyte code point", async ({ page }) => { test("delivers server heartbeat without mutating the timeline", async ({ page }) => { const timeline = await setupTimeline(page) const partID = "prt_transport_heartbeat_sentinel" - const sentinel = await timeline.transport.send(partUpdated(textPart(partID, "heartbeat sentinel"))) + const sentinel = (await timeline.transport.burst(partUpdated(textPart(partID, "heartbeat sentinel")))).at(-1)! await timeline.waitForPart(partID) - await expect(page.locator(`[data-timeline-part-id="${partID}"] [data-component="markdown"]`)).toHaveAttribute( - "data-markdown-ready", - "", - ) + await expect( + page.locator(`[data-timeline-part-id="${renderedPartID(partID)}"] [data-component="markdown"]`), + ).toHaveAttribute("data-markdown-ready", "") const before = await timelineRows(page) const heartbeat = await timeline.transport.heartbeat() @@ -66,7 +66,7 @@ test("reconnects after a clean close", async ({ page }) => { await timeline.transport.close() const second = await timeline.transport.waitForConnection({ after: first.id }) - await timeline.transport.send(partUpdated(textPart("prt_transport_close", "after close"))) + await timeline.transport.burst(partUpdated(textPart("prt_transport_close", "after close"))) await timeline.waitForPart("prt_transport_close") expect(second.id).toBeGreaterThan(first.id) @@ -79,7 +79,7 @@ test("reconnects after a stream error", async ({ page }) => { await timeline.transport.error("contract failure") const second = await timeline.transport.waitForConnection({ after: first.id }) - await timeline.transport.send(partUpdated(textPart("prt_transport_error", "after error"))) + await timeline.transport.burst(partUpdated(textPart("prt_transport_error", "after error"))) await timeline.waitForPart("prt_transport_error") await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2) @@ -89,9 +89,13 @@ test("reconnects after a stream error", async ({ page }) => { test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => { const timeline = await setupTimeline(page, { eventRetry: 10 }) - const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), { - id: "timeline-event-7", - }) + const events = partUpdated(textPart("prt_transport_id", "event with id")) + const first = ( + await timeline.transport.burst( + events, + events.map((_, index) => (index === events.length - 1 ? { id: "timeline-event-7" } : {})), + ) + ).at(-1)! await timeline.waitForPart("prt_transport_id") await timeline.transport.error("retry with event id") diff --git a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts deleted file mode 100644 index 31c4cb8b02d..00000000000 --- a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "@playwright/test" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectSessionTitle } from "../utils/waits" - -const directory = "C:/OpenCode/TodoDockNavigation" -const projectID = "proj_todo_dock_navigation" -const sourceID = "ses_todo_dock_source" -const otherID = "ses_todo_dock_other" -const sourceTitle = "Todo dock animation" -const otherTitle = "Separate session" - -const activeTodos = [ - { id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" }, - { id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" }, - { id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" }, -] - -type EventPayload = { - directory: string - payload: Record -} - -test.use({ viewport: { width: 1440, height: 900 } }) - -test("animates todo opening without replaying it across session tabs", async ({ page }) => { - test.setTimeout(90_000) - const events: EventPayload[] = [] - const todos: Record = { [sourceID]: [], [otherID]: [] } - const sessionStatus: Record = {} - - await mockOpenCodeServer(page, { - directory, - project: { - id: projectID, - worktree: directory, - vcs: "git", - name: "todo-dock-navigation", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - }, - provider: { - all: [ - { - id: "opencode", - name: "OpenCode", - models: { - "claude-opus-4-6": { - id: "claude-opus-4-6", - name: "Claude Opus 4.6", - limit: { context: 200_000 }, - }, - }, - }, - ], - connected: ["opencode"], - default: { providerID: "opencode", modelID: "claude-opus-4-6" }, - }, - sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)], - pageMessages: () => ({ items: [] }), - events: () => events.splice(0, 1), - eventRetry: 16, - sessionStatus: () => sessionStatus, - todos: (sessionID) => todos[sessionID] ?? [], - }) - await configurePage(page) - - await page.goto(sessionHref(sourceID)) - await expectSessionTitle(page, sourceTitle) - const dock = page.locator('[data-component="session-todo-dock"]') - await expect(dock).toHaveCount(0) - - sessionStatus[sourceID] = { type: "busy" } - events.push(statusEvent(sourceID, "busy")) - await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() - - await page.waitForTimeout(700) - const opening = sampleDock(page, 1_000) - todos[sourceID] = activeTodos - events.push(todoEvent(sourceID, activeTodos)) - await expect(dock).toBeVisible() - await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1) - expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true) - - await switchSession(page, otherID, otherTitle) - await expect(dock).toHaveCount(0) - - await switchSession(page, sourceID, sourceTitle) - await expect(dock).toHaveCount(0) -}) - -function session(id: string, title: string, created: number) { - return { - id, - slug: id, - projectID, - directory, - title, - version: "dev", - time: { created, updated: created }, - } -} - -function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload { - return { - directory, - payload: { type: "session.status", properties: { sessionID, status: { type } } }, - } -} - -function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload { - return { - directory, - payload: { type: "todo.updated", properties: { sessionID, todos: next } }, - } -} - -async function configurePage(page: Page) { - const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` - await page.addInitScript( - ({ directory, dirBase64, server, sessionIDs }) => { - localStorage.setItem( - "opencode.global.dat:server", - JSON.stringify({ - projects: { local: [{ worktree: directory, expanded: true }] }, - lastProject: { local: directory }, - }), - ) - localStorage.setItem( - "opencode.window.browser.dat:tabs", - JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))), - ) - }, - { directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] }, - ) -} - -function sessionHref(sessionID: string) { - const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` - return `/server/${base64Encode(server)}/session/${sessionID}` -} - -async function switchSession(page: Page, sessionID: string, title: string) { - const href = sessionHref(sessionID) - const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first() - await expect(tab).toBeVisible() - await tab.click() - await expectSessionTitle(page, title) -} - -function sampleDock(page: Page, duration: number) { - return page.evaluate(async (duration) => { - const samples: { present: boolean; height: number; opacity: number }[] = [] - const start = performance.now() - while (performance.now() - start < duration) { - const dock = document.querySelector('[data-component="session-todo-dock"]') - const clip = dock?.parentElement?.parentElement - const label = dock?.querySelector('[data-action="session-todo-toggle"] span[aria-label]') - samples.push({ - present: !!dock, - height: clip?.getBoundingClientRect().height ?? 0, - opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0, - }) - await new Promise(requestAnimationFrame) - } - return samples - }, duration) -} diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts index 1872afafa2f..a9588c37a84 100644 --- a/packages/app/e2e/regression/subagent-child-navigation.spec.ts +++ b/packages/app/e2e/regression/subagent-child-navigation.spec.ts @@ -1,4 +1,5 @@ import { base64Encode } from "@opencode-ai/core/util/encode" +import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise" import { expect, test, type Page } from "@playwright/test" import { currentSession, mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" @@ -12,8 +13,6 @@ const childTitle = "Subagent child session" // Child session pages derive their heading from the task part that spawned them. const taskDescription = "Inspect child navigation" -type EventPayload = { directory: string; payload: Record } - test.use({ viewport: { width: 1440, height: 900 } }) test("navigates to a subagent child session missing from the session list", async ({ page }) => { @@ -52,14 +51,18 @@ test("keeps the parent visible while child lineage resolves", async ({ page }) = }) test("shows the not found fallback when the viewed session is deleted", async ({ page }) => { - const events: EventPayload[] = [] + const events: OpenCodeEvent[] = [] await setup(page, () => events.splice(0, 1)) await openChildFromParent(page) await expectSessionTitle(page, taskDescription) events.push({ - directory, - payload: { type: "session.deleted", properties: { info: childSession() } }, + id: "evt_session_deleted", + created: 1700000003000, + type: "session.deleted", + durable: { aggregateID: childID, seq: 1, version: 2 }, + location: { directory }, + data: { sessionID: childID }, }) await expect(page.getByText("This session cannot be found")).toBeVisible() @@ -67,7 +70,7 @@ test("shows the not found fallback when the viewed session is deleted", async ({ await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0) }) -async function setup(page: Page, events?: () => EventPayload[]) { +async function setup(page: Page, events?: () => OpenCodeEvent[]) { await mockOpenCodeServer(page, { directory, project: { @@ -142,60 +145,36 @@ function childSession() { return session(childID, childTitle, 1700000001000, { parentID }) } -function parentMessages() { +function parentMessages(): SessionMessageInfo[] { const userID = "msg_user_0001" const assistantID = "msg_assistant_0001" return [ { - info: { - id: userID, - sessionID: parentID, - role: "user", - time: { created: 1700000000000 }, - agent: "build", - model: { providerID: "opencode", modelID: "claude-opus-4-6" }, - }, - parts: [ - { - id: "prt_user_text_0001", - sessionID: parentID, - messageID: userID, - type: "text", - text: "Delegate work to a subagent", - }, - ], + id: userID, + type: "user", + time: { created: 1700000000000 }, + text: "Delegate work to a subagent", }, { - info: { - id: assistantID, - sessionID: parentID, - role: "assistant", - time: { created: 1700000001000, completed: 1700000002000 }, - parentID: userID, - modelID: "claude-opus-4-6", - providerID: "opencode", - mode: "build", - agent: "build", - path: { cwd: directory, root: directory }, - cost: 0.01, - tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, - finish: "stop", - }, - parts: [ + id: assistantID, + type: "assistant", + time: { created: 1700000001000, completed: 1700000002000 }, + model: { id: "claude-opus-4-6", providerID: "opencode" }, + agent: "build", + cost: 0.01, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "stop", + content: [ { - id: "prt_tool_task_0001", - sessionID: parentID, - messageID: assistantID, type: "tool", - callID: "call_task_0001", - tool: "task", + id: "call_task_0001", + name: "task", + time: { created: 1700000001000, ran: 1700000001000, completed: 1700000002000 }, state: { status: "completed", input: { description: taskDescription, subagent_type: "explore" }, - output: "Subagent finished", - title: taskDescription, + content: [{ type: "text", text: "Subagent finished" }], metadata: { sessionId: childID }, - time: { start: 1700000001000, end: 1700000002000 }, }, }, ], diff --git a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts index a860a1af710..257d5dc1ca9 100644 --- a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts +++ b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts @@ -85,8 +85,7 @@ async function mockServer(page: Page) { await page.route("**/*", async (route) => { const url = new URL(route.request().url()) if (url.origin !== server) return route.fallback() - if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname)) - return new Promise(() => {}) + if (url.pathname === `/api/session/${unresolvedSessionID}`) return new Promise(() => {}) if (url.pathname === "/api/event") return sse(route) if (url.pathname === "/api/session") return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} }) @@ -95,41 +94,29 @@ async function mockServer(page: Page) { if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`)) return json(route, { data: [], cursor: {} }) - if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) - if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, []) - if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) - if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) - return json(route, []) - if (url.pathname === "/provider") - return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) - if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) - if (url.pathname === "/project" || url.pathname === "/project/current") { + if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname)) + return json(route, { location: { directory: sessionA.directory }, data: [] }) + if (url.pathname === "/api/model/default") + return json(route, { location: { directory: sessionA.directory }, data: null }) + if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request") + return json(route, { location: { directory: sessionA.directory }, data: [] }) + if (url.pathname === "/api/mcp") return json(route, { location: { directory: sessionA.directory }, data: [] }) + if (url.pathname === "/api/mcp/resource") + return json(route, { location: { directory: sessionA.directory }, data: { resources: [], templates: [] } }) + if (url.pathname === "/api/project" || url.pathname === "/api/project/current") { const project = { id: sessionA.projectID, - worktree: sessionA.directory, + canonical: sessionA.directory, vcs: "git", time: { created: 1, updated: 1 }, sandboxes: [], } - return json(route, url.pathname === "/project" ? [project] : project) + return json( + route, + url.pathname === "/api/project" ? [project] : { id: project.id, directory: sessionA.directory }, + ) } - if (url.pathname === "/path") - return json(route, { - state: sessionA.directory, - config: sessionA.directory, - worktree: sessionA.directory, - directory: sessionA.directory, - home: sessionA.directory, - }) - if (url.pathname === "/api/path") - return json(route, { - state: sessionA.directory, - config: sessionA.directory, - worktree: sessionA.directory, - directory: sessionA.directory, - home: sessionA.directory, - }) - if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (url.pathname === "/api/location") return json(route, { directory: sessionA.directory }) if (url.pathname === "/api/vcs") return json(route, { location: { directory: sessionA.directory }, diff --git a/packages/app/e2e/regression/terminal-composer-focus.spec.ts b/packages/app/e2e/regression/terminal-composer-focus.spec.ts index 6b96718a80d..5560c6cee99 100644 --- a/packages/app/e2e/regression/terminal-composer-focus.spec.ts +++ b/packages/app/e2e/regression/terminal-composer-focus.spec.ts @@ -1,4 +1,4 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" +import { base64Encode, checksum } from "@opencode-ai/core/util/encode" import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" @@ -8,12 +8,12 @@ const projectID = "proj_terminal_composer_focus" const sessionID = "ses_terminal_composer_focus" const ptyID = "pty_terminal_composer_focus" const newPtyID = "pty_terminal_composer_focus_new" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` test.use({ viewport: { width: 1440, height: 900 } }) test.beforeEach(async ({ page }) => { await mockOpenCodeServer(page, { - protocol: "v2", directory, project: { id: projectID, @@ -74,7 +74,7 @@ test.beforeEach(async ({ page }) => { }) test("routes typing to the composer unless the open terminal is focused", async ({ page }) => { - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) await expectSessionTitle(page, "Terminal composer focus") const composer = page.locator('[data-component="prompt-input"]') @@ -113,7 +113,7 @@ test("keeps composer focus when a cached terminal finishes mounting", async ({ p }) await seedCachedTerminal(page) - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" }) + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`, { waitUntil: "commit" }) await expectSessionTitle(page, "Terminal composer focus") const composer = page.locator('[data-component="prompt-input"]') @@ -139,7 +139,7 @@ test("keeps newer composer focus while an explicit terminal open finishes", asyn await route.continue() }) - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) await expectSessionTitle(page, "Terminal composer focus") const composer = page.locator('[data-component="prompt-input"]') @@ -184,7 +184,7 @@ test("focuses a terminal created from the new-terminal button", async ({ page }) ) await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined) - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) await expectSessionTitle(page, "Terminal composer focus") const composer = page.locator('[data-component="prompt-input"]') @@ -211,12 +211,18 @@ function seedCachedTerminal(page: Page) { }), ) }, - { terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID }, + { terminalKey: terminalStorageKey(), ptyID }, ) } +function terminalStorageKey() { + const dir = base64Encode(directory) + const head = dir.slice(0, 12).replace(/[^a-zA-Z0-9._-]/g, "-") + return `opencode.workspace.${head}.${checksum(dir) ?? "0"}.dat:workspace:terminal` +} + function ptyLocation() { - return { directory, project: { id: projectID, directory } } + return { directory, project: { id: projectID, directory, canonical: directory } } } function ptyInfo(id: string, title: string) { diff --git a/packages/app/e2e/regression/terminal-hidden.spec.ts b/packages/app/e2e/regression/terminal-hidden.spec.ts index 633dcb485ec..66a5a92ef1e 100644 --- a/packages/app/e2e/regression/terminal-hidden.spec.ts +++ b/packages/app/e2e/regression/terminal-hidden.spec.ts @@ -6,6 +6,7 @@ const directory = "C:/OpenCode/HiddenTerminalRegression" const projectID = "proj_hidden_terminal_regression" const sessionID = "ses_hidden_terminal_regression" const title = "Hidden terminal regression" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` test("unmounts the terminal panel while it is hidden", async ({ page }) => { await page.setViewportSize({ width: 1400, height: 900 }) @@ -91,7 +92,7 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => { ) await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined) - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) await expectSessionTitle(page, title) await page.keyboard.press("Control+Backquote") diff --git a/packages/app/e2e/smoke/session-timeline.fixture.ts b/packages/app/e2e/smoke/session-timeline.fixture.ts index 2ad400576f1..1fda6efa316 100644 --- a/packages/app/e2e/smoke/session-timeline.fixture.ts +++ b/packages/app/e2e/smoke/session-timeline.fixture.ts @@ -21,16 +21,29 @@ const words = [ "vector", ] -const serverKey = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` +const serverKey = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` const sourceID = "ses_smoke_source" const targetID = "ses_smoke_target" const directory = "C:/OpenCode/SmokeProject" const projectID = "proj_smoke_timeline" const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } -type MessageInfo = Record & { id: string; role: "user" | "assistant" } -type MessagePart = Record & { id: string; type: string; text?: string; tool?: string } -type Message = { info: MessageInfo; parts: MessagePart[] } +type MessagePart = { + id: string + type: "text" | "reasoning" | "tool" + text?: string + time?: { start: number; end?: number } + callID?: string + tool?: string + state?: { + status: "completed" + input: Record + output: string + title: unknown + metadata: Record + time: { start: number; end: number } + } +} function lorem(seed: number, length: number) { let out = "" @@ -48,54 +61,59 @@ function id(prefix: string, value: number) { return `${prefix}_smoke_${String(value).padStart(4, "0")}` } -function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message { +function userMessage(_sessionID: string, index: number, textLength: number, diffs: unknown[] = []): SessionMessageInfo { const messageID = id("msg_user", index) return { - info: { - id: messageID, - sessionID, - role: "user", - time: { created: 1700000000000 + index * 10_000 }, - summary: { diffs }, - agent: "build", - model, - }, - parts: [ - { - id: id("prt_user_text", index), - sessionID, - messageID, - type: "text", - text: lorem(index, textLength), - }, - ], + id: messageID, + type: "user", + time: { created: 1700000000000 + index * 10_000 }, + text: lorem(index, textLength), + metadata: diffs.length ? { diffs: diffs as JsonValue } : undefined, } } -function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message { +function assistantMessage( + _sessionID: string, + index: number, + _parentID: string, + parts: MessagePart[], +): SessionMessageInfo { const messageID = id("msg_assistant", index) return { - info: { - id: messageID, - sessionID, - role: "assistant", - time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 }, - parentID, - modelID: model.modelID, - providerID: model.providerID, - mode: "build", - agent: "build", - path: { cwd: directory, root: directory }, - cost: 0.01, - tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, - variant: "max", - finish: "stop", + id: messageID, + type: "assistant", + time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 }, + model: { id: model.modelID, providerID: model.providerID, variant: model.variant }, + agent: "build", + cost: 0.01, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "stop", + content: parts.map(messageContent), + } +} + +function messageContent(part: MessagePart): SessionMessageAssistant["content"][number] { + if (part.type === "text") return { type: "text", text: part.text ?? "" } + if (part.type === "reasoning") + return { + type: "reasoning", + text: part.text ?? "", + time: part.time + ? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) } + : undefined, + } + const state = part.state! + return { + type: "tool", + id: part.callID ?? part.id, + name: part.tool!, + time: { created: state.time.start, ran: state.time.start, completed: state.time.end }, + state: { + status: "completed", + input: state.input as Record, + content: [{ type: "text", text: state.output }], + metadata: state.metadata as Record, }, - parts: parts.map((part) => ({ - ...part, - sessionID, - messageID, - })), } } @@ -180,7 +198,7 @@ function code(seed: number, lines: number) { ) } -function turn(index: number): Message[] { +function turn(index: number): SessionMessageInfo[] { const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : [] const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff) const parts = [ @@ -219,7 +237,7 @@ function turn(index: number): Message[] { ? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)] : []), ] - return [user, assistantMessage(targetID, index, user.info.id, parts)] + return [user, assistantMessage(targetID, index, user.id, parts)] } const targetMessages = Array.from({ length: 101 }, (_, index) => turn(index)).flat() @@ -227,22 +245,16 @@ const sourceMessages = Array.from({ length: 12 }, (_, index) => [ userMessage(sourceID, index + 1000, 120), assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]), ]).flat() -const messages: Record = { [sourceID]: sourceMessages, [targetID]: targetMessages } +const messages: Record = { [sourceID]: sourceMessages, [targetID]: targetMessages } -function renderable(part: MessagePart) { - if (part.type === "tool" && part.tool === "todowrite") return false - if (part.type === "text") return !!part.text?.trim() - if (part.type === "reasoning") return !!part.text?.trim() - return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch" -} - -function currentPartIDs(message: Message) { +function currentPartIDs(message: SessionMessageInfo) { + if (message.type === "user") return message.text.trim() ? [`${message.id}:text:0`] : [] + if (message.type !== "assistant") return [] const ordinals = { text: 0, reasoning: 0 } - return message.parts.flatMap((part) => { - if (!renderable(part)) return [] - if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`] - if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`] - if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id] + return message.content.flatMap((part) => { + if (part.type === "text") return part.text.trim() ? [`${message.id}:text:${ordinals.text++}`] : [] + if (part.type === "reasoning") return part.text.trim() ? [`${message.id}:reasoning:${ordinals.reasoning++}`] : [] + if (part.type === "tool") return [part.id] return [] }) } @@ -295,12 +307,11 @@ export const fixture = { expected: { sourceTitle: "Uncommitted changes inquiry", targetTitle: "Example Game: sample jump movement & sample physics analysis", - targetMessageIDs: targetMessages - .filter((message) => message.info.role === "user") - .map((message) => message.info.id), + targetMessageIDs: targetMessages.filter((message) => message.type === "user").map((message) => message.id), targetPartIDs: targetMessages.flatMap(currentPartIDs), - expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")! - .callID, + expandedShellPartID: targetMessages + .flatMap((message) => (message.type === "assistant" ? message.content : [])) + .flatMap((part) => (part.type === "tool" && part.name === "bash" ? [part.id] : []))[0], }, } @@ -309,12 +320,13 @@ export function pageMessages(sessionID: string, limit: number, before?: string) const end = before ? Math.max( 0, - messages.findIndex((message) => message.info.id === before), + messages.findIndex((message) => message.id === before), ) : messages.length const start = Math.max(0, end - limit) return { items: messages.slice(start, end), - cursor: start > 0 ? messages[start].info.id : undefined, + cursor: start > 0 ? messages[start].id : undefined, } } +import type { JsonValue, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise" diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index d1cf5d5bf98..67090c9095a 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -33,7 +33,6 @@ test.describe("smoke: session timeline", () => { test("keeps the visible message fixed while prepending history", async ({ page }) => { const requests: { before?: string; phase: "start" | "end"; at: number }[] = [] await mockOpenCodeServer(page, { - protocol: "v2", sessions: fixture.sessions, provider: fixture.provider, directory: fixture.directory, @@ -44,7 +43,7 @@ test.describe("smoke: session timeline", () => { }) await configureSmokePage(page, fixture.directory) - await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle) + await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle) await waitForTimelineStable(page) const scroller = timelineScroller(page) await pointAtTimeline(page) @@ -92,7 +91,6 @@ test.describe("smoke: session timeline", () => { test("preserves the timeline gap above the composer", async ({ page }) => { await mockOpenCodeServer(page, { - protocol: "v2", sessions: fixture.sessions, provider: fixture.provider, directory: fixture.directory, @@ -101,7 +99,7 @@ test.describe("smoke: session timeline", () => { }) await configureSmokePage(page, fixture.directory) - await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle) + await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle) await waitForTimelineStable(page) const scroller = timelineScroller(page) await scroller.evaluate((element) => { @@ -119,7 +117,6 @@ test.describe("smoke: session timeline", () => { test("paints cached session tabs at the latest message", async ({ page }) => { await mockOpenCodeServer(page, { - protocol: "v2", sessions: fixture.sessions, provider: fixture.provider, directory: fixture.directory, @@ -143,11 +140,11 @@ test.describe("smoke: session timeline", () => { { server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID }, ) - await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`) + await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${fixture.targetID}`) await expectSessionTitle(page, fixture.expected.targetTitle) await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle) - const destination = fixture.messages[fixture.targetID].map((message) => message.info.id) + const destination = fixture.messages[fixture.targetID].map((message) => message.id) const last = fixture.expected.targetMessageIDs.at(-1)! await page.evaluate( ({ destination, last }) => { @@ -262,7 +259,6 @@ test.describe("smoke: session timeline", () => { test("paints a cold session tab at the latest message", async ({ page }) => { await mockOpenCodeServer(page, { - protocol: "v2", sessions: fixture.sessions, provider: fixture.provider, directory: fixture.directory, @@ -285,10 +281,10 @@ test.describe("smoke: session timeline", () => { }, { server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID }, ) - await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`) + await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${fixture.sourceID}`) await expectSessionTitle(page, fixture.expected.sourceTitle) const last = fixture.expected.targetMessageIDs.at(-1)! - const destination = fixture.messages[fixture.targetID].map((message) => message.info.id) + const destination = fixture.messages[fixture.targetID].map((message) => message.id) await page.evaluate( ({ destination, last }) => { const ids = new Set(destination) @@ -341,7 +337,6 @@ test.describe("smoke: session timeline", () => { test("renders seeded timeline in order while paging through history", async ({ page }) => { const errors = trackPageErrors(page) await mockOpenCodeServer(page, { - protocol: "v2", sessions: fixture.sessions, provider: fixture.provider, directory: fixture.directory, @@ -351,9 +346,9 @@ test.describe("smoke: session timeline", () => { await configureSmokePage(page, fixture.directory) await selectHomeProject(page, fixture.project.name) - await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle) + await navigateToSession(page, fixture.sourceID, fixture.expected.sourceTitle) await expectSessionReady(page) - await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle) + await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle) const expectedPartIDs = fixture.expected.targetPartIDs const expectedMessageIDs = fixture.expected.targetMessageIDs await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors) @@ -742,8 +737,8 @@ async function selectHomeProject(page: Page, projectName: string) { await expect(page).toHaveURL(/\/$/) } -async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) { - await page.goto(`/${base64Encode(directory)}/session/${sessionId}`) +async function navigateToSession(page: Page, sessionId: string, expectedTitle: string) { + await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionId}`) await expectSessionTitle(page, expectedTitle) } diff --git a/packages/app/e2e/user-story/model-selection-flow.spec.ts b/packages/app/e2e/user-story/model-selection-flow.spec.ts index 4ea566771fc..7da878180e8 100644 --- a/packages/app/e2e/user-story/model-selection-flow.spec.ts +++ b/packages/app/e2e/user-story/model-selection-flow.spec.ts @@ -6,7 +6,6 @@ const directory = "C:/OpenCode/NewProject" test("creates a session in a new project and selects its model", async ({ page }) => { await mockOpenCodeServer(page, { - protocol: "v2", directory, project: { id: "proj_model_selection_flow", diff --git a/packages/app/e2e/utils/errors.ts b/packages/app/e2e/utils/errors.ts index 74eb302b841..610c34744a6 100644 --- a/packages/app/e2e/utils/errors.ts +++ b/packages/app/e2e/utils/errors.ts @@ -6,6 +6,9 @@ export function trackPageErrors(page: Page) { if (message.type() === "error") errors.push(message.text()) }) page.on("pageerror", (error) => errors.push(error.stack ?? error.message)) + page.on("response", (response) => { + if (response.status() >= 400) errors.push(`${response.status()} ${response.url()}`) + }) return errors } diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 1968ebd2946..d7c876a64e3 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -1,37 +1,30 @@ import type { Page, Route } from "@playwright/test" -import type { - JsonValue, - PromptAgentAttachment, - PromptFileAttachment, - SessionMessageAssistant, - SessionMessageInfo, - SessionStructuredError, -} from "@opencode-ai/client/promise" - -const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"]) -const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"]) +import type { JsonValue, OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise" export interface MockServerConfig { - protocol?: "v1" | "v2" provider: unknown | (() => unknown) integrationMethods?: Record onConnectKey?: (input: { integrationID: string; body: unknown }) => void - onInstanceDispose?: () => void directory: string project: unknown sessions: ({ id: string } & Record)[] - pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string } + pageMessages: ( + sessionId: string, + limit: number, + before?: string, + ) => { + items: SessionMessageInfo[] + cursor?: string + } vcsDiff?: unknown[] messageDelay?: number beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void - message?: (sessionID: string, messageID: string) => unknown + message?: (sessionID: string, messageID: string) => SessionMessageInfo | undefined onMessage?: (input: { sessionID: string; messageID: string }) => void - events?: () => unknown[] + events?: () => OpenCodeEvent[] eventRetry?: number - todos?: (sessionID: string) => unknown[] permissions?: unknown[] | (() => unknown[]) - questions?: unknown[] | (() => unknown[]) forms?: unknown[] | (() => unknown[]) fileList?: (path: string) => unknown | Promise fileContent?: (path: string) => unknown | Promise @@ -46,10 +39,11 @@ type MockStreamWindow = Window & { export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const cursors = new Map() + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` let nextCursor = 0 await page.addInitScript( - ({ port, retry }) => { + ({ server, retry }) => { const host = window as MockStreamWindow if (host.__testSseTransport || host.__mockServerStream) return const originalFetch = window.fetch.bind(window) @@ -74,7 +68,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const fetch = (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init) const url = new URL(request.url) - if (url.port !== port || url.pathname !== "/api/event") return originalFetch(request) + if (url.origin !== server || url.pathname !== "/api/event") return originalFetch(request) state.connections += 1 const id = state.connections let ended = false @@ -114,7 +108,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { } Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch }) }, - { port: process.env.PLAYWRIGHT_SERVER_PORT ?? "4096", retry: config.eventRetry }, + { server, retry: config.eventRetry }, ) if (config.events) { @@ -125,10 +119,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (batch.length === 0) return pump.busy = true void page - .evaluate( - (payloads) => (window as MockStreamWindow).__mockServerStream?.push(payloads), - batch.map(currentEvent), - ) + .evaluate((payloads) => (window as MockStreamWindow).__mockServerStream?.push(payloads), batch as unknown[]) .catch(() => {}) .finally(() => { pump.busy = false @@ -136,87 +127,33 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }, 50) page.on("close", () => clearInterval(timer)) } - const staticRoutes: Record = { - "/path": { - state: config.directory, - config: config.directory, - worktree: config.directory, - directory: config.directory, - home: "C:/OpenCode", - }, - "/project": [config.project], - "/project/current": config.project, - "/agent": [{ name: "build", mode: "primary" }], - "/vcs": { branch: "main", default_branch: "main" }, - "/session": config.sessions, - } await page.route("**/*", async (route) => { const url = new URL(route.request().url()) const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" const appPort = new URL( process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`, ).port - if (url.port !== targetPort && url.port !== appPort) return route.fallback() + if (url.origin !== server && url.port !== appPort) return route.fallback() const path = url.pathname - if (path === "/global/event" || path === "/event" || path === "/api/event") { + if (path === "/api/event") { const events = config.events?.() return sse( route, - path === "/api/event" - ? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])] - : [ - ...(path === "/global/event" - ? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }] - : []), - ...(events ?? []), - ], + [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events ?? [])], config.eventRetry, ) } - if (path === "/global/health") - return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true }) if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 }) - if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true }) - if (path === "/provider") return json(route, providerConfig(config)) - if (path === "/provider/auth") return json(route, config.integrationMethods ?? {}) - const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1] - if (legacyAuth && route.request().method() === "PUT") { - config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() }) - return json(route, true) - } - if (path === "/instance/dispose" && route.request().method() === "POST") { - config.onInstanceDispose?.() - return json(route, true) - } - if (path === "/permission") - return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) - if (path === "/question") - return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? [])) - if (path === "/session/status") - return json( - route, - typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}), - ) - if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff) - if (path === "/file" && config.fileList) - return json(route, await config.fileList(url.searchParams.get("path") ?? "")) - if (path === "/file/content" && config.fileContent) - return json(route, await config.fileContent(url.searchParams.get("path") ?? "")) - if (path === "/find/file" && config.findFiles) - return json( - route, - await config.findFiles({ - query: url.searchParams.get("query") ?? "", - dirs: url.searchParams.get("dirs") ?? undefined, - limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined, - }), - ) if (path === "/api/reference") return json(route, { location: { directory: config.directory, - project: { id: (config.project as { id?: string }).id, directory: config.directory }, + project: { + id: (config.project as { id?: string }).id, + directory: config.directory, + canonical: config.directory, + }, }, data: [], }) @@ -277,7 +214,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { ]) } if (path === "/api/project/current") - return json(route, { id: (config.project as { id?: string }).id, directory: config.directory }) + return json(route, { + id: (config.project as { id?: string }).id, + directory: config.directory, + canonical: config.directory, + }) const worktree = path.match(/^\/api\/worktree\/([^/]+)$/)?.[1] if (worktree && route.request().method() === "GET") return json(route, [ @@ -303,18 +244,13 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { currentPermission, ), }) - if (path === "/api/question/request") - return json(route, { - location: location(config), - data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []), - }) if (path === "/api/form/request") return json(route, { location: location(config), data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []), }) if (path === "/api/vcs") - return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } }) + return json(route, { location: location(config), data: { branch: { current: "main", default: "main" } } }) if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] }) if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] }) if (path === "/api/fs/list" && config.fileList) @@ -352,10 +288,26 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { : entries, }) } - if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] }) + if (path === "/api/shell" && route.request().method() === "GET") + return json(route, { location: location(config), data: [] }) if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path)) return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } }) if (path === "/api/session") { + if (route.request().method() === "POST") { + const payload = route.request().postDataJSON() as Record + const created = currentSession( + { + id: "ses_mock_created", + projectID: (config.project as { id?: string }).id, + title: typeof payload.title === "string" ? payload.title : "New session", + parentID: typeof payload.parentID === "string" ? payload.parentID : undefined, + }, + config.directory, + ) + config.sessions.push(created) + return json(route, { data: created }) + } + if (route.request().method() !== "GET") return route.fallback() const directory = url.searchParams.get("directory") const parentID = url.searchParams.get("parentID") const limit = Number(url.searchParams.get("limit") ?? 50) @@ -365,7 +317,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const location = session.location as { directory?: string } | undefined return !directory || location?.directory === directory || session.directory === directory }) - .filter((session) => parentID !== "null" || session.parentID === undefined) + .filter((session) => { + if (parentID === null) return true + if (parentID === "null") return session.parentID === undefined + return session.parentID === parentID + }) .filter((session) => { const search = url.searchParams.get("search")?.toLowerCase() return ( @@ -375,7 +331,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { .includes(search) ) }) - const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions + const ordered = url.searchParams.get("order") === "asc" ? sessions : sessions.toReversed() const data = ordered.slice(offset, offset + limit) const next = offset + limit < ordered.length ? String(offset + limit) : undefined return json(route, { @@ -398,9 +354,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") { return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) } - if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") { - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } const sessionForm = path.match(/^\/api\/session\/([^/]+)\/form$/)?.[1] if (sessionForm && route.request().method() === "GET") { const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? []) @@ -416,12 +369,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") { return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) } - if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") - return json(route, true) - if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") - return json(route, true) if ( - /^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) && + /^\/api\/session\/[^/]+\/(rename|interrupt|revert\/clear|revert\/commit)$/.test(path) && route.request().method() === "POST" ) { return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) @@ -429,10 +378,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") { return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) } - if (emptyObject.has(path)) return json(route, {}) - if (emptyList.has(path)) return json(route, []) - if (path in staticRoutes) return json(route, staticRoutes[path]) - const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/) if (currentSessionMatch) { const session = config.sessions.find((item) => item.id === currentSessionMatch[1]) @@ -442,69 +387,37 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }) } - const currentMessageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/) - if (currentMessageMatch) { - config.onMessage?.({ sessionID: currentMessageMatch[1]!, messageID: currentMessageMatch[2]! }) - if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) - const message = config.message?.(currentMessageMatch[1]!, currentMessageMatch[2]!) - if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404) - return json(route, { data: currentMessage(message) }) - } - - const sessionMatch = path.match(/^\/session\/([^/]+)$/) - if (sessionMatch) return json(route, config.sessions.find((session) => session.id === sessionMatch[1]) ?? {}) - - const projectMatch = path.match(/^\/project\/([^/]+)$/) - if (projectMatch) return json(route, config.project) - - const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/) + const messageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/) if (messageMatch) { config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! }) if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) - const message = config.message?.(messageMatch[1]!, messageMatch[2]!) + const message = + config.message?.(messageMatch[1]!, messageMatch[2]!) ?? + config.pageMessages(messageMatch[1]!, Number.MAX_SAFE_INTEGER).items.find((item) => item.id === messageMatch[2]) if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404) - return json(route, message) + return json(route, { data: message }) } - const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/) - if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? []) - if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) - - const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/) - if (currentMessagesMatch) { - const token = url.searchParams.get("cursor") ?? undefined - const before = token ? cursors.get(token) : undefined - if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400) - config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" }) - await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before }) - if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) - const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before) - config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" }) - const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined - if (cursor) cursors.set(cursor, pageData.cursor!) - return json(route, { - data: pageData.items.map(currentMessage).reverse(), - cursor: { next: cursor }, - }) - } - - const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) + const messagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/) if (messagesMatch) { - const token = url.searchParams.get("before") ?? undefined + const token = url.searchParams.get("cursor") ?? undefined const before = token ? cursors.get(token) : undefined if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400) config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" }) await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before }) if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) - const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 80), before) + const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before) config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" }) - if (!pageData.cursor) return json(route, pageData.items) - const cursor = `cursor_${++nextCursor}` - cursors.set(cursor, pageData.cursor) - return json(route, pageData.items, { "x-next-cursor": cursor }) + const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined + if (cursor) cursors.set(cursor, pageData.cursor!) + return json(route, { + data: url.searchParams.get("order") === "asc" ? pageData.items : pageData.items.toReversed(), + cursor: { next: cursor }, + }) } - if (url.port === targetPort && targetPort !== appPort) return json(route, {}) + if (url.port === targetPort && targetPort !== appPort) + return json(route, { error: `Unhandled mock route: ${path}` }, undefined, 404) return route.fallback() }) } @@ -522,13 +435,21 @@ function providerConfig(config: MockServerConfig) { function currentProviders(value: unknown) { if (!record(value) || !Array.isArray(value.all)) return Array.isArray(value) ? value : [] - return value.all - .filter(record) - .flatMap((provider) => - typeof provider.id === "string" && typeof provider.name === "string" - ? [{ id: provider.id, name: provider.name, package: provider.id }] - : [], - ) + const connected = new Set( + Array.isArray(value.connected) ? value.connected.filter((id) => typeof id === "string") : [], + ) + return value.all.filter(record).flatMap((provider) => + typeof provider.id === "string" && typeof provider.name === "string" + ? [ + { + id: provider.id, + name: provider.name, + package: provider.id, + activation: connected.has(provider.id) ? "enabled" : "auto", + }, + ] + : [], + ) } function currentModels(value: unknown) { @@ -584,7 +505,7 @@ function currentDefaultModel(value: unknown) { function currentPermission(value: unknown) { const permission = value as Record if (permission.action) return permission - const tool = permission.tool as { messageID?: string; callID?: string } | undefined + const tool = permission.tool as { messageID?: string; callID?: string; id?: string } | undefined return { id: permission.id, sessionID: permission.sessionID, @@ -593,7 +514,9 @@ function currentPermission(value: unknown) { save: permission.always, metadata: permission.metadata, source: - tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined, + tool?.messageID && (tool.id || tool.callID) + ? { type: "tool", messageID: tool.messageID, id: tool.id ?? tool.callID } + : undefined, } } @@ -634,202 +557,6 @@ export function currentSession(session: { id: string } & Record } } -export function currentMessage(value: unknown): SessionMessageInfo { - if (isCurrentMessage(value)) return value - if (!record(value) || !record(value.info) || !Array.isArray(value.parts)) throw new Error("Invalid message fixture") - - const info = value.info - const parts = value.parts.filter(record) - if (typeof info.id !== "string" || !record(info.time) || typeof info.time.created !== "number") - throw new Error("Invalid legacy message fixture") - - const time = { - created: info.time.created, - ...(typeof info.time.completed === "number" ? { completed: info.time.completed } : {}), - } - if (info.role === "user") { - return { - id: info.id, - type: "user", - time: { created: time.created }, - text: parts - .flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : [])) - .join("\n"), - files: parts.flatMap((part) => (part.type === "file" ? legacyFile(part) : [])), - agents: parts.flatMap((part) => (part.type === "agent" ? legacyAgent(part) : [])), - } - } - if (info.role !== "assistant") throw new Error("Invalid legacy message role") - - return { - id: info.id, - type: "assistant", - time, - agent: typeof info.agent === "string" ? info.agent : typeof info.mode === "string" ? info.mode : "build", - model: { - id: typeof info.modelID === "string" ? info.modelID : "model", - providerID: typeof info.providerID === "string" ? info.providerID : "provider", - ...(typeof info.variant === "string" ? { variant: info.variant } : {}), - }, - content: parts.flatMap((part) => legacyAssistantContent(part, time.created)), - ...(typeof info.cost === "number" ? { cost: info.cost } : {}), - ...(tokens(info.tokens) ? { tokens: tokens(info.tokens) } : {}), - ...(structuredError(info.error) ? { error: structuredError(info.error) } : {}), - ...(finish(info.finish) ? { finish: finish(info.finish) } : {}), - } -} - -function isCurrentMessage(value: unknown): value is SessionMessageInfo { - return record(value) && typeof value.id === "string" && typeof value.type === "string" && !record(value.info) -} - -function legacyFile(part: Record): PromptFileAttachment[] { - if (typeof part.mime !== "string" || typeof part.url !== "string") return [] - const data = part.url.match(/^data:[^,]*;base64,(.*)$/)?.[1] ?? "" - const source = record(part.source) ? part.source : undefined - const sourceText = source && record(source.text) ? source.text : undefined - const mention = mentionFrom(sourceText) - const uri = source?.type === "resource" && typeof source.uri === "string" ? source.uri : part.url - return [ - { - data, - mime: part.mime, - source: part.url.startsWith("data:") ? { type: "inline" } : { type: "uri", uri }, - ...(typeof part.filename === "string" ? { name: part.filename } : {}), - ...(mention ? { mention } : {}), - }, - ] -} - -function legacyAgent(part: Record): PromptAgentAttachment[] { - if (typeof part.name !== "string") return [] - const mention = mentionFrom(record(part.source) ? part.source : undefined) - return [{ name: part.name, ...(mention ? { mention } : {}) }] -} - -function mentionFrom(value: Record | undefined) { - if (!value || typeof value.value !== "string" || typeof value.start !== "number" || typeof value.end !== "number") - return - return { text: value.value, start: value.start, end: value.end } -} - -function legacyAssistantContent(part: Record, created: number): SessionMessageAssistant["content"] { - if (part.type === "text" && typeof part.text === "string") - return [ - { type: "text", text: part.text, ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}) }, - ] - if (part.type === "reasoning" && typeof part.text === "string") { - const time = record(part.time) ? part.time : undefined - return [ - { - type: "reasoning", - text: part.text, - ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}), - ...(time && typeof time.start === "number" - ? { - time: { - created: time.start, - ...(typeof time.end === "number" ? { completed: time.end } : {}), - }, - } - : {}), - }, - ] - } - if (part.type !== "tool" || typeof part.id !== "string" || typeof part.tool !== "string" || !record(part.state)) - return [] - - const state = part.state - const time = record(state.time) ? state.time : undefined - const toolTime = { - created: time && typeof time.start === "number" ? time.start : created, - ...(time && typeof time.start === "number" ? { ran: time.start } : {}), - ...(time && typeof time.end === "number" ? { completed: time.end } : {}), - } - const input = jsonRecord(state.input) ?? {} - const metadata = jsonRecord(state.metadata) - const base = { - type: "tool" as const, - id: typeof part.callID === "string" ? part.callID : part.id, - name: part.tool, - time: toolTime, - ...(typeof part.executed === "boolean" ? { executed: part.executed } : {}), - ...(jsonRecord(part.providerState) ? { providerState: jsonRecord(part.providerState) } : {}), - ...(jsonRecord(part.providerResultState) ? { providerResultState: jsonRecord(part.providerResultState) } : {}), - } - if (state.status === "pending") - return [ - { - ...base, - state: { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(input) }, - }, - ] - if (state.status === "completed") - return [ - { - ...base, - state: { - status: "completed", - input, - content: [{ type: "text", text: typeof state.output === "string" ? state.output : "" }], - ...(metadata ? { metadata } : {}), - }, - }, - ] - if (state.status === "error") - return [ - { - ...base, - state: { - status: "error", - input, - error: structuredError(state.error) ?? { type: "ToolError", message: "Tool failed" }, - ...(metadata ? { metadata } : {}), - }, - }, - ] - return [{ ...base, state: { status: "running", input, metadata: metadata ?? {} } }] -} - -function structuredError(value: unknown): SessionStructuredError | undefined { - if (typeof value === "string") return { type: "Error", message: value } - if (!record(value)) return - if (typeof value.type === "string" && typeof value.message === "string") - return { type: value.type, message: value.message } - if (typeof value.name !== "string" || !record(value.data) || typeof value.data.message !== "string") return - return { type: value.name, message: value.data.message } -} - -function tokens(value: unknown): SessionMessageAssistant["tokens"] | undefined { - if (!record(value) || !record(value.cache)) return - if ( - typeof value.input !== "number" || - typeof value.output !== "number" || - typeof value.reasoning !== "number" || - typeof value.cache.read !== "number" || - typeof value.cache.write !== "number" - ) - return - return { - input: value.input, - output: value.output, - reasoning: value.reasoning, - cache: { read: value.cache.read, write: value.cache.write }, - } -} - -function finish(value: unknown): SessionMessageAssistant["finish"] | undefined { - if ( - value === "stop" || - value === "length" || - value === "tool-calls" || - value === "content-filter" || - value === "error" || - value === "unknown" - ) - return value -} - function jsonRecord(value: unknown): Record | undefined { if (!record(value)) return return Object.fromEntries( @@ -871,18 +598,3 @@ function sse(route: Route, events?: unknown[], retry?: number) { body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`, }) } - -function currentEvent(input: unknown) { - if (!input || typeof input !== "object" || !("payload" in input)) return input - const envelope = input as { directory?: string; payload?: unknown } - if (!envelope.payload || typeof envelope.payload !== "object") return input - const payload = envelope.payload as { id?: string; type?: string; properties?: unknown } - if (!payload.type) return input - return { - id: payload.id ?? `evt_mock_${Date.now()}`, - created: Date.now(), - type: payload.type, - data: payload.properties ?? {}, - location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined, - } -} diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index fdfe13f85b2..cd72cab1aac 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -1,4 +1,5 @@ import type { Page } from "@playwright/test" +import type { OpenCodeEvent } from "@opencode-ai/client/promise" export type SseConnectionRecord = { id: number @@ -27,7 +28,7 @@ export type SseEventOptions = { marker?: string } -export type SseTransport = { +export type SseTransport = { server: string waitForConnection(options?: { after?: number; timeout?: number }): Promise send(payload: T, options?: SseEventOptions): Promise @@ -55,7 +56,7 @@ type BrowserTransport = Window & { } } -export async function installSseTransport( +export async function installSseTransport( page: Page, options: { server: string; retry?: number }, ): Promise> { @@ -93,21 +94,6 @@ export async function installSseTransport( eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`, `data: ${JSON.stringify(payload)}\n\n`, ].join("") - const currentEvent = (input: unknown) => { - if (!input || typeof input !== "object" || !("payload" in input)) return input - const envelope = input as { directory?: string; payload?: unknown } - if (!envelope.payload || typeof envelope.payload !== "object") return input - const payload = envelope.payload as { id?: string; type?: string; properties?: unknown } - if (!payload.type) return input - return { - id: payload.id ?? `evt_mock_${Date.now()}`, - created: Date.now(), - type: payload.type, - data: payload.properties ?? {}, - location: - envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined, - } - } const acknowledge = ( connection: Connection, bytes: number, @@ -155,10 +141,11 @@ export async function installSseTransport( output.forEach((chunk) => connection.controller.enqueue(chunk)) return acknowledge(connection, input.bytes.length, output.length) } - const encoded = input.deliveries.map((delivery) => { - const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload - return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) } - }) + const encoded = input.deliveries.map((delivery) => ({ + delivery, + payload: delivery.payload, + bytes: encoder.encode(frame(delivery.payload, delivery.options)), + })) encoded.forEach((item) => marker(item.delivery.options?.marker)) if (input.burst) { const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join("")) @@ -266,15 +253,11 @@ export async function installSseTransport( return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, cuts: [...cuts] }) }, heartbeat(eventOptions) { + const bytes = new TextEncoder().encode(": heartbeat\n\n") return command({ - type: "send", - deliveries: [ - { - payload: { directory: "global", payload: { type: "server.heartbeat", properties: {} } } as T, - options: eventOptions, - }, - ], - burst: false, + type: "raw", + bytes: Array.from(bytes), + marker: eventOptions?.marker, }) }, writeRaw(value, cuts, marker) { diff --git a/packages/app/e2e/utils/visual-stability.ts b/packages/app/e2e/utils/visual-stability.ts index 2c56864ed8d..5041a5f3232 100644 --- a/packages/app/e2e/utils/visual-stability.ts +++ b/packages/app/e2e/utils/visual-stability.ts @@ -1,6 +1,6 @@ import type { Page, TestInfo } from "@playwright/test" import { analyzeVisualObservations, analyzeVisualTraceByMarker } from "./visual-stability/analyzer" -import { legacyVisualPlan, type LegacyVisualStabilityOptions } from "./visual-stability/invariant" +import { stabilityPlan, type VisualStabilityOptions } from "./visual-stability/invariant" import type { CapturedFrame, VisualStabilityTrace } from "./visual-stability/model" import { markVisualProbe, startVisualProbe, stopVisualProbe } from "./visual-stability/probe" import type { VisualRegionDefinition } from "./visual-stability/regions" @@ -25,22 +25,19 @@ export async function markVisualStability(page: Page, label: string) { await markVisualProbe(page, label) } -export function analyzeVisualStability(trace: VisualStabilityTrace, options: LegacyVisualStabilityOptions = {}) { - return analyzeVisualObservations(trace.samples, legacyVisualPlan(options)) +export function analyzeVisualStability(trace: VisualStabilityTrace, options: VisualStabilityOptions = {}) { + return analyzeVisualObservations(trace.samples, stabilityPlan(options)) } -export function analyzeVisualStabilityByMarker( - trace: VisualStabilityTrace, - options: LegacyVisualStabilityOptions = {}, -) { - return analyzeVisualTraceByMarker(trace, legacyVisualPlan(options)) +export function analyzeVisualStabilityByMarker(trace: VisualStabilityTrace, options: VisualStabilityOptions = {}) { + return analyzeVisualTraceByMarker(trace, stabilityPlan(options)) } export async function expectVisualStability( testInfo: TestInfo, name: string, trace: VisualStabilityTrace, - options: LegacyVisualStabilityOptions = {}, + options: VisualStabilityOptions = {}, ) { await reportVisualStability( testInfo, @@ -49,6 +46,6 @@ export async function expectVisualStability( ...trace, frames: (trace as VisualStabilityTrace & { [capturedFrames]?: CapturedFrame[] })[capturedFrames] ?? [], }, - legacyVisualPlan(options), + stabilityPlan(options), ) } diff --git a/packages/app/e2e/utils/visual-stability/invariant.ts b/packages/app/e2e/utils/visual-stability/invariant.ts index 17f6ef19136..91d8b31ee49 100644 --- a/packages/app/e2e/utils/visual-stability/invariant.ts +++ b/packages/app/e2e/utils/visual-stability/invariant.ts @@ -30,7 +30,7 @@ export type VisualPlan = { aggregateMotion?: boolean } -export type LegacyVisualStabilityOptions = { +export type VisualStabilityOptions = { flow?: RegionName[] motionTolerance?: number opacityFloor?: number @@ -58,8 +58,8 @@ export function visualPlan[], invariants } } -export function legacyVisualPlan( - options: LegacyVisualStabilityOptions = {}, +export function stabilityPlan( + options: VisualStabilityOptions = {}, ): VisualPlan { const inferred = options.inferRequired === false diff --git a/packages/app/public/oc-theme-preload.js b/packages/app/public/oc-theme-preload.js index 11c9b39ec4c..1c1795d1a74 100644 --- a/packages/app/public/oc-theme-preload.js +++ b/packages/app/public/oc-theme-preload.js @@ -2,13 +2,6 @@ var key = "opencode-theme-id" var themeId = localStorage.getItem(key) || "oc-2" - if (themeId === "oc-1") { - themeId = "oc-2" - localStorage.setItem(key, themeId) - localStorage.removeItem("opencode-theme-css-light") - localStorage.removeItem("opencode-theme-css-dark") - } - var scheme = localStorage.getItem("opencode-color-scheme") || "system" var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches) var mode = isDark ? "dark" : "light" diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index f2d086c0a82..ebdfd89a252 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -12,7 +12,6 @@ import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchPara import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { type Component, - createEffect, createMemo, createRenderEffect, ErrorBoundary, @@ -32,7 +31,7 @@ import { LayoutProvider } from "@/context/layout" import { ModelsProvider } from "@/context/models" import { usePlatform } from "@/context/platform" import { PromptProvider } from "@/context/prompt" -import { ServerConnection, ServersProvider, useServers } from "@/context/servers" +import { ServerConnection, ServersProvider } from "@/context/servers" import { SettingsProvider } from "@/context/settings" import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs" import { SDKProvider } from "@/context/sdk" @@ -40,8 +39,7 @@ import { WslServersProvider } from "@/wsl/context" import { DirectoryDataProvider } from "@/pages/directory-layout" import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" -import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route" -import { decode64 } from "@/utils/base64" +import { requireServerKey } from "./utils/session-route" import { TargetSessionRouteContent } from "@/pages/session" import { Home } from "@/pages/home" @@ -49,22 +47,6 @@ import { ServerProvider } from "./context/server" const NewSession = lazy(() => import("@/pages/new-session")) -const DirectoryDraftRedirect = () => { - const params = useParams() - const [search] = useSearchParams<{ draftId?: string; prompt?: string }>() - const server = useServers() - const tabs = useTabs() - - createEffect(() => { - if (search.draftId || !tabs.ready()) return - const directory = decode64(params.dir) - if (!directory) return - tabs.newDraft({ server: ServerConnection.key(server.list[0])!, directory }, search.prompt) - }) - - return null -} - function TargetServerRoute(props: ParentProps) { const params = useParams<{ serverKey: string; id: string }>() const global = useGlobal() @@ -264,8 +246,6 @@ export function AppInterface(props: { canonicalLocalServer?: ServerConnection.Key servers?: Array router?: Component - disableHealthCheck?: boolean - startup?: Promise }) { // The visual layout lives in the router root so it remains mounted across // route changes. Draft and session routes override only their server-bound data @@ -292,7 +272,6 @@ export function AppInterface(props: { - {/* Proper Routes */} - {/* Legacy Routes */} - - - - - ) } - -function LegacySessionRedirect() { - const server = useServers() - const tabs = useTabs() - const params = useParams<{ id: string }>() - - return ( - - item.type === "session"), - params.id, - ServerConnection.key(server.list[0]!), - ), - params.id, - )} - /> - - ) -} diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 6fe0c445601..2771a8193ac 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -11,6 +11,8 @@ import { extractPromptFromParts } from "@/utils/prompt" import type { TextPart as SDKTextPart } from "@/types" import { base64Encode } from "@opencode-ai/core/util/encode" import { useLanguage } from "@/context/language" +import { useServer } from "@/context/server" +import { sessionHref } from "@/utils/session-route" interface ForkableMessage { id: string @@ -30,6 +32,7 @@ export const DialogFork: Component = () => { const prompt = usePrompt() const dialog = useDialog() const language = useLanguage() + const server = useServer() const messages = createMemo((): ForkableMessage[] => { const sessionID = params.id @@ -73,7 +76,7 @@ export const DialogFork: Component = () => { .then((forked) => { dialog.close() prompt.set(restored, undefined, { dir, id: forked.id }) - navigate(`/${dir}/session/${forked.id}`) + navigate(sessionHref(server.key, forked.id)) }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) diff --git a/packages/app/src/components/prompt-input/history-store.ts b/packages/app/src/components/prompt-input/history-store.ts index 3858b8b9bc6..b0c39a6cf0e 100644 --- a/packages/app/src/components/prompt-input/history-store.ts +++ b/packages/app/src/components/prompt-input/history-store.ts @@ -16,6 +16,21 @@ export type PromptInputHistory = { type PromptHistoryState = { entries: PromptHistoryStoredEntry[] } +export function upgradeHistoryState(value: unknown) { + if (!value || typeof value !== "object" || Array.isArray(value) || !("entries" in value)) return value + const entries = value.entries + if (!Array.isArray(entries)) return value + return { + ...value, + entries: entries.flatMap((entry): PromptHistoryStoredEntry[] => { + if (Array.isArray(entry)) return [{ prompt: clonePromptParts(entry as Prompt), comments: [] }] + if (!entry || typeof entry !== "object" || !("prompt" in entry) || !Array.isArray(entry.prompt)) return [] + if (!("comments" in entry) || !Array.isArray(entry.comments)) return [] + return [entry as PromptHistoryStoredEntry] + }), + } +} + function createPromptInputHistoryStore( normal: Store, setNormal: SetStoreFunction, @@ -42,11 +57,11 @@ export function createPromptInputHistory(): PromptInputHistory { export function createPersistedPromptInputHistory() { const [normal, setNormal, normalInit] = persisted( - Persist.prompt(Persist.global("prompt-history", ["prompt-history.v1"])), + { ...Persist.prompt(Persist.global("prompt-history")), migrate: upgradeHistoryState }, createStore({ entries: [] }), ) const [shell, setShell, shellInit] = persisted( - Persist.prompt(Persist.global("prompt-history-shell", ["prompt-history-shell.v1"])), + { ...Persist.prompt(Persist.global("prompt-history-shell")), migrate: upgradeHistoryState }, createStore({ entries: [] }), ) const history = createPromptInputHistoryStore(normal, setNormal, shell, setShell) diff --git a/packages/app/src/components/prompt-input/history.test.ts b/packages/app/src/components/prompt-input/history.test.ts index 94f24ce935f..dbbffc0de36 100644 --- a/packages/app/src/components/prompt-input/history.test.ts +++ b/packages/app/src/components/prompt-input/history.test.ts @@ -3,16 +3,17 @@ import type { Prompt } from "@/context/prompt" import { canNavigateHistoryAtCursor, clonePromptParts, - normalizePromptHistoryEntry, navigatePromptHistory, prependHistoryEntry, promptLength, type PromptHistoryComment, } from "./history" +import { upgradeHistoryState } from "./history-store" const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }] +const entry = (value: string) => ({ prompt: text(value), comments: [] }) const comment = (id: string, value = "note"): PromptHistoryComment => ({ id, path: "src/a.ts", @@ -42,7 +43,7 @@ describe("prompt-input history", () => { }) test("navigatePromptHistory restores saved prompt when moving down from newest", () => { - const entries = [text("third"), text("second"), text("first")] + const entries = [entry("third"), entry("second"), entry("first")] const up = navigatePromptHistory({ direction: "up", entries, @@ -95,10 +96,10 @@ describe("prompt-input history", () => { expect(up.entry.comments).toEqual([comment("c1")]) }) - test("normalizePromptHistoryEntry supports legacy prompt arrays", () => { - const entry = normalizePromptHistoryEntry(text("legacy")) - expect(entry.prompt[0]?.type === "text" ? entry.prompt[0].content : "").toBe("legacy") - expect(entry.comments).toEqual([]) + test("upgrades stored prompt arrays once at the persistence boundary", () => { + expect(upgradeHistoryState({ entries: [text("stored")] })).toEqual({ + entries: [{ prompt: text("stored"), comments: [] }], + }) }) test("helpers clone prompt and count text content length", () => { diff --git a/packages/app/src/components/prompt-input/history.ts b/packages/app/src/components/prompt-input/history.ts index 79e8abc0d9e..d589d502469 100644 --- a/packages/app/src/components/prompt-input/history.ts +++ b/packages/app/src/components/prompt-input/history.ts @@ -20,7 +20,7 @@ export type PromptHistoryEntry = { comments: PromptHistoryComment[] } -export type PromptHistoryStoredEntry = Prompt | PromptHistoryEntry +export type PromptHistoryStoredEntry = PromptHistoryEntry export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) { const position = Math.max(0, Math.min(cursor, text.length)) @@ -60,12 +60,6 @@ export function clonePromptHistoryComments(comments: PromptHistoryComment[]) { } export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry { - if (Array.isArray(entry)) { - return { - prompt: clonePromptParts(entry), - comments: [], - } - } return { prompt: clonePromptParts(entry.prompt), comments: clonePromptHistoryComments(entry.comments), diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index c54bee7cfae..b65f2c3788f 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -39,6 +39,7 @@ const updatedDrafts: Array<{ draftID: string; worktree?: string }> = [] const syncedServers: string[] = [] const admittedServers: string[] = [] const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = [] +const navigations: string[] = [] let serverSessionSyncs = 0 let restoredPrompts = 0 let clearEchoCalls = 0 @@ -55,12 +56,12 @@ let promptFailure: Error | undefined let clearEchoResult = true let worktreeCreates = 0 let activeSDK = "server-a" +let activeServer = "server-a" let activeServerSync = "server-a" let activeDirectorySync = "server-a" let commands: Array<{ name: string }> = [] let worktreeDirectory = "/repo/new-0" let worktreeID = 0 -const draftServers: Record = {} const sessionDirectories: Record = {} let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] @@ -178,7 +179,7 @@ beforeAll(async () => { const rootClient = clientFor("/repo/main") mock.module("@solidjs/router", () => ({ - useNavigate: () => () => undefined, + useNavigate: () => (href: string) => navigations.push(href), useParams: () => params, useLocation: () => ({}), useSearchParams: () => [search, () => undefined], @@ -191,7 +192,10 @@ beforeAll(async () => { })) mock.module("@opencode-ai/core/util/encode", () => ({ + base64Decode: (value: string) => value, base64Encode: (value: string) => value, + checksum: (value: string) => value, + sampledChecksum: (value: string) => value, })) mock.module("@/context/local", () => ({ @@ -214,12 +218,11 @@ beforeAll(async () => { }) mock.module("@/context/server", () => ({ - useServer: () => ({ key: "server-key" }), + useServer: () => ({ key: activeServer }), })) mock.module("@/context/tabs", () => ({ useTabs: () => ({ - draft: (draftID: string) => ({ server: draftServers[draftID] ?? "project-server" }), updateDraft: (draftID: string, draft: { worktree?: string }) => { updatedDrafts.push({ draftID, ...draft }) }, @@ -343,6 +346,7 @@ beforeEach(() => { syncedServers.length = 0 admittedServers.length = 0 promptCaptures.length = 0 + navigations.length = 0 restoredPrompts = 0 clearEchoCalls = 0 params = {} @@ -352,6 +356,7 @@ beforeEach(() => { selected = "/repo/worktree-a" variant = undefined activeSDK = "server-a" + activeServer = "server-a" activeServerSync = "server-a" activeDirectorySync = "server-a" commands = [] @@ -365,7 +370,6 @@ beforeEach(() => { promptFailure = undefined clearEchoResult = true worktreeCreates = 0 - for (const key of Object.keys(draftServers)) delete draftServers[key] for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key] for (const key of Object.keys(storedSessions)) delete storedSessions[key] }) @@ -414,6 +418,7 @@ describe("prompt submit worktree selection", () => { expect(worktreeCreates).toBe(1) expect(createdSessions).toHaveLength(1) expect(sentPrompts).toEqual([worktreeDirectory]) + expect(navigations).toEqual(["/server/server-a/session/session-1"]) }) test("stops when the created workspace cannot initialize", async () => { @@ -429,8 +434,6 @@ describe("prompt submit worktree selection", () => { test("keeps async submission effects bound to the initiating context", async () => { search = { draftId: "draft-1" } - draftServers["draft-1"] = "project-server-a" - draftServers["draft-2"] = "project-server-b" let release = () => {} createSessionGate = new Promise((resolve) => { release = resolve @@ -442,6 +445,7 @@ describe("prompt submit worktree selection", () => { const result = submit.handleSubmit(event) activeSDK = "server-b" + activeServer = "server-b" activeServerSync = "server-b" activeDirectorySync = "server-b" search.draftId = "draft-2" @@ -450,10 +454,10 @@ describe("prompt submit worktree selection", () => { await settle() expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }]) - expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }]) + expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "server-a", sessionId: "session-1" }]) expect(syncedServers.every((server) => server === "server-a")).toBe(true) expect(admittedServers).toEqual(["server-a"]) - expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local }) + expect(promptCaptures.at(-1)?.target).toEqual({ server: "server-a", scope: ServerScope.local }) expect(submitted).toBe(0) }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index e2cdfc0fde3..1e3ca51c87d 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -21,6 +21,8 @@ import { ScopedKey } from "@/utils/server-scope" import { createPromptSubmissionState } from "./submission-state" import { Event } from "@opencode-ai/schema/event" import { blobDataUrl } from "@/utils/draft-store" +import { useServer } from "@/context/server" +import { sessionHref } from "@/utils/session-route" const submitting = new Set() @@ -189,6 +191,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const sdk = useSDK() const sync = useSync() const serverSync = useServerSync() + const server = useServer() const local = useLocal() const permission = usePermission() const prompt = input.prompt @@ -291,12 +294,12 @@ export function createPromptSubmit(input: PromptSubmitInput) { const submissionSync = sync() const submissionServerSync = serverSync const submissionScope = submissionSDK.scope + const submissionServer = server.key const projectDirectory = submissionSDK.directory const sessionID = params.id const isNewSession = !sessionID const currentSession = input.info() const draftID = search.draftId - const draftServer = draftID ? tabs.draft(draftID).server : undefined const capturePrompt = prompt.capture const localSession = local.session const resetWorktree = input.onNewSessionWorktreeReset @@ -376,12 +379,12 @@ export function createPromptSubmit(input: PromptSubmitInput) { model: { providerID: currentModel.provider.id, modelID: currentModel.id }, variant: variant ?? null, }) - if (draftID && draftServer) tabs.promoteDraft(draftID, { server: draftServer, sessionId: session.id }) - else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) + if (draftID) tabs.promoteDraft(draftID, { server: submissionServer, sessionId: session.id }) + else navigate(sessionHref(submissionServer, session.id)) submission.retarget( capturePrompt( { dir: base64Encode(sessionDirectory), id: session.id }, - { server: draftServer, scope: submissionScope }, + { server: submissionServer, scope: submissionScope }, ), ) }) diff --git a/packages/app/src/components/settings-v2/settings-v2.css b/packages/app/src/components/settings-v2/settings-v2.css index a40a6999602..1aa382803a7 100644 --- a/packages/app/src/components/settings-v2/settings-v2.css +++ b/packages/app/src/components/settings-v2/settings-v2.css @@ -210,15 +210,6 @@ color: var(--v2-text-text-faint); } -.settings-v2-legacy-panel { - height: 100%; - overflow: hidden; -} - -.settings-v2-legacy-panel [data-component="dialog"] { - display: contents; -} - .settings-v2-provider-row { display: flex; flex-wrap: wrap; diff --git a/packages/app/src/components/settings-v2/workspaces.tsx b/packages/app/src/components/settings-v2/workspaces.tsx index 3b316bbe066..48454fdf837 100644 --- a/packages/app/src/components/settings-v2/workspaces.tsx +++ b/packages/app/src/components/settings-v2/workspaces.tsx @@ -176,12 +176,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr worktree: undefined, }) }) - clearWorkspaceTerminals( - workspace.directory, - preflight.sessions.map((session) => session.id), - platform, - context.sdk.scope, - ) + clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope) context.sync.set( "project", produce((draft) => { diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 3ecdfa4d227..6434a642b8f 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -327,7 +327,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl > - + platform: ReturnType - variant?: "legacy" | "v2" }) { let lastFocused: HTMLElement | undefined const language = useLanguage() @@ -50,35 +48,20 @@ export function WindowsAppMenu(props: { return ( - - } +
-
- } - aria-label={language.t("desktop.menu.ariaLabel")} - onPointerDown={rememberFocus} - onKeyDown={rememberFocus} - /> -
- + } + aria-label={language.t("desktop.menu.ariaLabel")} + onPointerDown={rememberFocus} + onKeyDown={rememberFocus} + /> +
diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx index 02e66505704..cac4d8cc6f5 100644 --- a/packages/app/src/context/comments.tsx +++ b/packages/app/src/context/comments.tsx @@ -179,10 +179,8 @@ export function createCommentSessionForTest(comments: Record({ comments: {}, }), diff --git a/packages/app/src/context/file/view-cache.ts b/packages/app/src/context/file/view-cache.ts index 87b859dea82..ad80d5c3bf9 100644 --- a/packages/app/src/context/file/view-cache.ts +++ b/packages/app/src/context/file/view-cache.ts @@ -35,10 +35,8 @@ function equalSelectedLines(a: SelectedLineRange | null | undefined, b: Selected } function createViewSession(scope: ServerScope, dir: string, id: string | undefined) { - const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1` - const [view, setView, _, ready] = persisted( - Persist.serverScoped(scope, dir, id, "file-view", [legacyViewKey]), + Persist.serverScoped(scope, dir, id, "file-view"), createStore<{ file: Record }>({ diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 78e00ab9590..cdfb0509d61 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -156,7 +156,7 @@ export function createChildStoreManager(input: { if (!children[key]) { const vcs = runWithOwner(input.owner, () => input.persist( - Persist.serverWorkspace(input.scope, directory, "vcs", ["vcs.v1"]), + Persist.serverWorkspace(input.scope, directory, "vcs"), createStore({ value: undefined as VcsInfo | undefined }), ), ) @@ -166,7 +166,7 @@ export function createChildStoreManager(input: { const meta = runWithOwner(input.owner, () => input.persist( - Persist.serverWorkspace(input.scope, directory, "project", ["project.v1"]), + Persist.serverWorkspace(input.scope, directory, "project"), createStore({ value: undefined as ProjectMeta | undefined }), ), ) @@ -175,7 +175,7 @@ export function createChildStoreManager(input: { const icon = runWithOwner(input.owner, () => input.persist( - Persist.serverWorkspace(input.scope, directory, "icon", ["icon.v1"]), + Persist.serverWorkspace(input.scope, directory, "icon"), createStore({ value: undefined as string | undefined }), ), ) diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index ec2cf0a11fa..517b074bc5d 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -181,7 +181,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont init: (props: { locale?: Locale; onNativeTranslations?: (bundle: DesktopNativeBundle) => void }) => { const initial = props.locale ?? readStoredLocale() ?? detectLocale() const [store, setStore, _, ready] = persisted( - Persist.global("language", ["language.v1"]), + Persist.global("language"), createStore({ locale: initial, }), diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 875ceacdd6b..2fa2d832249 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -13,7 +13,7 @@ import { same } from "@/utils/same" import { createScrollPersistence, type SessionScroll } from "./layout-scroll" import { createPathHelpers } from "./file/path" import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2" -import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope" +import { SessionStateKey } from "@/utils/server-scope" import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers" import { requireServerKey } from "@/utils/session-route" import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs" @@ -133,11 +133,6 @@ export const currentRoute = (pathname: string, search: string): LayoutRoute => { } } - const dirBase64 = parts[0] - const dir = decode64(dirBase64) - if (!dir) return { type: "home" } - if (parts[1] === "session") return { type: "home" } - throw new Error("Unrecognised route!") } @@ -157,6 +152,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) + const currentSessionState = (value: unknown) => { + if (!isRecord(value)) return value + const entries = Object.entries(value) + if (entries.every(([key]) => SessionStateKey.is(key))) return value + return Object.fromEntries(entries.filter(([key]) => SessionStateKey.is(key))) + } + const migrate = (value: unknown) => { if (!isRecord(value)) return value @@ -198,8 +200,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( } })() - const sessionTabs = migrateLegacySessionStateKeys(value.sessionTabs) - const sessionView = migrateLegacySessionStateKeys(value.sessionView) + const sessionTabs = currentSessionState(value.sessionTabs) + const sessionView = currentSessionState(value.sessionView) const migratedSessionTabs = (() => { if (!isRecord(sessionTabs)) return sessionTabs @@ -245,7 +247,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( } const [store, setStore, _, ready] = persisted( - { ...Persist.global("layout", ["layout.v6"]), migrate }, + { ...Persist.global("layout"), previousKey: "layout.v6", migrate }, createStore({ sidebar: { opened: false, @@ -292,11 +294,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( used: new Map(), } - const SESSION_STATE_KEYS = [ - { key: "prompt", legacy: "prompt", version: "v2" }, - { key: "terminal", legacy: "terminal", version: "v1" }, - { key: "file-view", legacy: "file", version: "v1" }, - ] as const + const SESSION_STATE_KEYS = ["prompt", "terminal", "file-view"] as const const dropSessionState = (keys: string[]) => { for (const key of keys) { @@ -308,13 +306,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( for (const entry of SESSION_STATE_KEYS) { const target = session - ? Persist.serverSession(scope, dir, session, entry.key) - : Persist.serverWorkspace(scope, dir, entry.key) + ? Persist.serverSession(scope, dir, session, entry) + : Persist.serverWorkspace(scope, dir, entry) void removePersisted(target, platform) - - if (scope !== ServerScope.local) continue - const legacyKey = `${dir}/${entry["legacy"]}${session ? "/" + session : ""}.${entry.version}` - void removePersisted({ key: legacyKey }, platform) } } } diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index c63c11aa9c5..ac0dfda14ab 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -73,7 +73,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const [saved, setSaved, , savedReady] = persisted( { - ...Persist.serverWorkspace(serverSDK.scope, sdk().directory, "model-selection", ["model-selection.v1"]), + ...Persist.serverWorkspace(serverSDK.scope, sdk().directory, "model-selection"), migrate, }, createStore({ diff --git a/packages/app/src/context/models.tsx b/packages/app/src/context/models.tsx index d480268cbdc..808deddfd70 100644 --- a/packages/app/src/context/models.tsx +++ b/packages/app/src/context/models.tsx @@ -24,7 +24,7 @@ function modelKey(model: ModelKey) { const createModelsPersistedState = () => { const [store, setStore, _, ready] = persisted( - Persist.global("model", ["model.v1"]), + Persist.global("model"), createStore({ user: [], recent: [], diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index a4f2fa21a53..008b6d9d1c6 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -116,7 +116,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser const empty: Notification[] = [] const [store, setStore, _, ready] = persisted( - Persist.serverGlobal(input.sdk.scope, "notification", ["notification.v1"]), + Persist.serverGlobal(input.sdk.scope, "notification"), createStore({ list: [] as Notification[], }), diff --git a/packages/app/src/context/permission-auto-respond.test.ts b/packages/app/src/context/permission-auto-respond.test.ts index ee8f5404f22..1cf50090df6 100644 --- a/packages/app/src/context/permission-auto-respond.test.ts +++ b/packages/app/src/context/permission-auto-respond.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from "bun:test" import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise" import { base64Encode } from "@opencode-ai/core/util/encode" -import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond" +import { + autoRespondsPermission, + isDirectoryAutoAccepting, + relocateAutoAccept, + sessionAutoAccept, +} from "./permission-auto-respond" const session = (input: { id: string; parentID?: string }) => ({ @@ -25,12 +30,6 @@ describe("autoRespondsPermission", () => { expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true) }) - test("uses a parent session's legacy auto-accept key", () => { - const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] - - expect(autoRespondsPermission({ root: true }, sessions, permission("child"), "/tmp/project")).toBe(true) - }) - test("defaults to requiring approval when no lineage override exists", () => { const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })] const autoAccept = { @@ -123,3 +122,13 @@ describe("isDirectoryAutoAccepting", () => { expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false) }) }) + +test("relocates bare session settings when the directory becomes known", () => { + const directory = "/tmp/project" + expect(relocateAutoAccept({ root: true }, [{ id: "root" }], directory)).toEqual({ + [`${base64Encode(directory)}/root`]: true, + }) + expect( + relocateAutoAccept({ root: true, [`${base64Encode(directory)}/root`]: false }, [{ id: "root" }], directory), + ).toEqual({ [`${base64Encode(directory)}/root`]: false }) +}) diff --git a/packages/app/src/context/permission-auto-respond.ts b/packages/app/src/context/permission-auto-respond.ts index 8fff6d70c17..7828de607e2 100644 --- a/packages/app/src/context/permission-auto-respond.ts +++ b/packages/app/src/context/permission-auto-respond.ts @@ -10,8 +10,7 @@ export function directoryAcceptKey(directory: string) { } function accepted(autoAccept: Record, sessionID: string, directory?: string) { - const key = acceptKey(sessionID, directory) - return autoAccept[key] ?? autoAccept[sessionID] + return autoAccept[acceptKey(sessionID, directory)] } export function isDirectoryAutoAccepting(autoAccept: Record, directory: string) { @@ -19,6 +18,26 @@ export function isDirectoryAutoAccepting(autoAccept: Record, di return autoAccept[key] ?? false } +export function relocateAutoAccept( + autoAccept: Record, + sessions: readonly { id: string }[], + directory: string, +) { + const moves = sessions.flatMap((session) => { + const value = autoAccept[session.id] + if (value === undefined) return [] + return [{ source: session.id, target: acceptKey(session.id, directory), value }] + }) + if (moves.length === 0) return autoAccept + + const next = { ...autoAccept } + for (const move of moves) { + if (next[move.target] === undefined) next[move.target] = move.value + delete next[move.source] + } + return next +} + function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) { const parent = session.reduce((acc, item) => { if (item.parentID) acc.set(item.id, item.parentID) diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index 6905a16aec6..2e7a01deafd 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -1,5 +1,5 @@ import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" -import { createStore, produce } from "solid-js/store" +import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import type { PermissionRequest } from "@opencode-ai/client/promise" import { Persist, persisted } from "@/utils/persist" @@ -12,12 +12,13 @@ import { ServerConnection, useServers } from "./servers" import { type DraftTab, useTabs } from "./tabs" import { useSettings } from "./settings" import { requireServerKey } from "@/utils/session-route" -import type { ServerScope } from "@/utils/server-scope" +import { ServerScope } from "@/utils/server-scope" import { acceptKey, directoryAcceptKey, isDirectoryAutoAccepting, autoRespondsPermission, + relocateAutoAccept, sessionAutoAccept, } from "./permission-auto-respond" import { useServer } from "./server" @@ -57,7 +58,8 @@ type PermissionEvent = Parameters[0]>[0 export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) { const [store, setStore, _, ready] = persisted( { - ...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]), + ...Persist.serverGlobal(input.sdk.scope, "permission"), + ...(input.sdk.scope === ServerScope.local ? { previousKey: "permission.v3" } : {}), migrate(value) { if (!value || typeof value !== "object" || Array.isArray(value)) return value @@ -147,8 +149,15 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve return [...info, ...input.sync.child(directory, { bootstrap: false })[0].session] } + function autoAccept(directory?: string) { + if (!directory) return store.autoAccept + const next = relocateAutoAccept(store.autoAccept, sessions(directory), directory) + if (next !== store.autoAccept) setStore("autoAccept", reconcile(next)) + return next + } + function isAutoAccepting(sessionID: string, directory?: string) { - return autoRespondsPermission(store.autoAccept, sessions(directory), { sessionID }, directory) + return autoRespondsPermission(autoAccept(directory), sessions(directory), { sessionID }, directory) } function isAutoAcceptingDirectory(directory: string) { @@ -156,7 +165,7 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve } function shouldAutoRespond(permission: PermissionRequest, directory?: string) { - return autoRespondsPermission(store.autoAccept, sessions(directory), permission, directory) + return autoRespondsPermission(autoAccept(directory), sessions(directory), permission, directory) } function isPending(permission: PermissionRequest) { @@ -165,7 +174,7 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve } async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) { - const override = sessionAutoAccept(store.autoAccept, sessions(directory), permission, directory) + const override = sessionAutoAccept(autoAccept(directory), sessions(directory), permission, directory) if (override !== undefined) return override if (input.sync.session.lineage.peek(permission.sessionID)) return shouldAutoRespond(permission, directory) const lineage = await input.sync.session.lineage.resolve(permission.sessionID).catch(() => undefined) diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 17b564b4567..e155989146c 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -68,9 +68,6 @@ type PlatformBase = { /** Prompt drafts, history, and their blobs. */ draftStore?: DraftStore - /** Stable platform window identity for window-scoped persistence */ - windowID?: string - /** Application-global desktop updater */ updater?: UpdaterPlatform @@ -86,12 +83,6 @@ type PlatformBase = { /** Manage WSL sidecar servers (Electron on Windows only) */ wslServers?: WslServersPlatform - /** Get the preferred display backend (desktop only) */ - getDisplayBackend?(): Promise | DisplayBackend | null - - /** Set the preferred display backend (desktop only) */ - setDisplayBackend?(backend: DisplayBackend): Promise - /** Webview zoom level (desktop only) */ webviewZoom?: Accessor @@ -125,16 +116,16 @@ type PlatformBase = { export type Platform = PlatformBase & ( - | { platform: "web"; os?: never } + | { platform: "web"; os?: never; windowID?: never } | { platform: "desktop" os?: DesktopOS + /** Stable platform window identity for window-scoped persistence */ + windowID: string openDirectoryPickerDialog(opts?: OpenDirectoryPickerOptions): Promise } ) -export type DisplayBackend = "auto" | "wayland" - export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({ name: "Platform", init: (props: { value: Platform }) => { diff --git a/packages/app/src/context/prompt-state.ts b/packages/app/src/context/prompt-state.ts index 03fa68bc8ea..dbcf725e1ab 100644 --- a/packages/app/src/context/prompt-state.ts +++ b/packages/app/src/context/prompt-state.ts @@ -4,7 +4,7 @@ import { batch, createMemo, type Accessor } from "solid-js" import { createStore, type SetStoreFunction } from "solid-js/store" import type { FileSelection } from "@/context/file" import { Persist, persisted } from "@/utils/persist" -import type { ServerScope } from "@/utils/server-scope" +import { ServerScope } from "@/utils/server-scope" import type { BlobReference } from "@/utils/draft-store" import type { Platform } from "@/context/platform" @@ -171,8 +171,12 @@ function createPromptActions(setStore: SetStoreFunction) { function promptTarget(serverScope: ServerScope, scope: PromptScope) { if ("draftID" in scope) return Persist.prompt(Persist.draft(scope.draftID, "prompt")) - const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` - return Persist.prompt(Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])) + return Persist.prompt({ + ...Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt"), + ...(serverScope === ServerScope.local + ? { previousKey: `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` } + : {}), + }) } function promptStore(initial?: InitialPrompt): PromptStore { diff --git a/packages/app/src/context/server-session-v2-reducer.test.ts b/packages/app/src/context/server-session-v2-reducer.test.ts index c18e2b668e3..2d805a6c2d7 100644 --- a/packages/app/src/context/server-session-v2-reducer.test.ts +++ b/packages/app/src/context/server-session-v2-reducer.test.ts @@ -129,11 +129,11 @@ describe("v2 session reducer", () => { }, }), ) - const legacyAgent = reducer.reduce( + const derivedAgent = reducer.reduce( source, event({ ...base, - id: "evt_legacy_agent", + id: "evt_derived_agent", type: "session.agent.selected", data: { sessionID: "ses_1", agent: "plan" }, }), @@ -145,7 +145,7 @@ describe("v2 session reducer", () => { model: { id: "new" }, previous: { id: "durable" }, }) - expect(legacyAgent?.messages.at(-1)).toMatchObject({ + expect(derivedAgent?.messages.at(-1)).toMatchObject({ type: "agent-switched", agent: "plan", previous: "build", diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index 263e2acb44a..3da2615e9bb 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -28,14 +28,12 @@ const session = (id: string, parentID?: string): SessionInfo => ({ }) type UserMessage = Extract -type AssistantMessage = Extract type TextPart = Extract type CurrentToolObject = Extract["input"] type MessageResponse = { data: { info: Message; parts: Part[] }[] response: { headers: Headers } } -type SingleMessageResponse = { data: MessageResponse["data"][number] } function sessionInfo(value: SessionInfo): SessionInfo { return value @@ -157,25 +155,6 @@ const userMessage = (id: string, input: Partial = {}): UserMessage ...input, }) -const assistantMessage = (id: string, parentID: string, input: Partial = {}): AssistantMessage => ({ - id, - sessionID: "child", - role: "assistant", - time: { created: Number(id.at(-1)), completed: Number(id.at(-1)) }, - parentID, - modelID: "model", - providerID: "provider", - variant: undefined, - mode: "build", - agent: "build", - path: { cwd: "", root: "" }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - error: undefined, - finish: undefined, - ...input, -}) - const textPart = (messageID: string, input: Partial = {}): TextPart => ({ sessionID: "child", messageID, @@ -200,8 +179,6 @@ const response = (data: MessageResponse["data"] = [], cursor?: string): MessageR response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) }, }) -const singleResponse = (info: Message, parts: Part[] = []): SingleMessageResponse => ({ data: { info, parts } }) - const deferredResponse = () => Promise.withResolvers() function messageClient(...responses: Array>) { @@ -236,43 +213,6 @@ function messageClient(...responses: Array>, - roots: Array>, -) { - let pageIndex = 0 - let rootIndex = 0 - const requests: unknown[] = [] - const rootRequests: unknown[] = [] - const rootWaiting = new Map void>() - const client = { - session: { - get: async () => sessionInfo(session("child", "root")), - message: async (input: unknown) => { - rootRequests.push(input) - rootWaiting.get(rootRequests.length)?.() - rootWaiting.delete(rootRequests.length) - const value = await roots[rootIndex++]! - return currentMessages([value.data]).find((message) => message.id === value.data.info.id)! - }, - }, - message: { - list: async (input: unknown) => { - requests.push(input) - return currentPage(await pages[pageIndex++]!) - }, - }, - } as unknown as { session: SessionApi; message: MessageApi } - return Object.assign(client, { - requests, - rootRequests, - rootRequested(count: number) { - if (rootRequests.length >= count) return Promise.resolve() - return new Promise((resolve) => rootWaiting.set(count, resolve)) - }, - }) -} - const retryImmediately: typeof retry = async (task, options = {}) => { const attempts = options.attempts ?? 3 for (let attempt = 0; ; attempt++) { @@ -329,7 +269,7 @@ describe("server session", () => { expect(ctx.get).toEqual([{ sessionID: "created" }]) }) - test("projects V2 session events into current and legacy message state", () => { + test("projects V2 session events into native and rendering message state", () => { const ctx = setup({ child: session("child") }) ctx.store.remember(session("child")) ctx.store.set("session_message", "child", [ @@ -785,239 +725,6 @@ describe("server session", () => { expect(store.history.more("child")).toBe(false) }) - // V2 messages are ordered projections and do not expose V1 assistant parent IDs. - describe.skip("V1 assistant parent projections", () => { - test("backfills an assistant-only initial page through its user root", async () => { - const user = userMessage("message-1") - const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] - const client = rootMessageClient( - [response(assistants.map((info) => ({ info, parts: [] })))], - [singleResponse(user)], - ) - const store = createServerSession(client) - - await store.sync("child") - - expect(client.requests).toEqual([{ sessionID: "child", limit: 200, order: "desc" }]) - expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }]) - expect(store.data.message.child).toEqual([user, ...assistants]) - expect(store.history.more("child")).toBe(false) - }) - - test("keeps assistant history when its deleted parent cannot be backfilled", async () => { - const missing = Promise.withResolvers() - const assistant = assistantMessage("message-2", "message-missing") - const client = rootMessageClient([response([{ info: assistant, parts: [] }])], [missing.promise]) - const store = createServerSession(client) - const loading = store.sync("child") - await client.rootRequested(1) - - missing.reject(new Error("Message not found: message-missing", { cause: { status: 404 } })) - await loading - - expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }]) - expect(store.data.message.child).toEqual([assistant]) - expect(store.history.more("child")).toBe(false) - }) - - test("drops a cached parent when a forced refresh confirms it was deleted", async () => { - const missing = Promise.withResolvers() - const parent = userMessage("message-1") - const part = textPart(parent.id) - const assistant = assistantMessage("message-2", parent.id) - const client = rootMessageClient( - [ - response([ - { info: parent, parts: [part] }, - { info: assistant, parts: [] }, - ]), - response([{ info: assistant, parts: [] }]), - ], - [missing.promise], - ) - const store = createServerSession(client) - await store.sync("child") - const loading = store.sync("child", { force: true }) - await client.rootRequested(1) - - missing.reject(new Error(`Message not found: ${parent.id}`, { cause: { status: 404 } })) - await loading - - expect(store.data.message.child).toEqual([assistant]) - expect(store.data.part[parent.id]).toBeUndefined() - }) - - test("does not let an admitted user suppress initial root backfill", async () => { - const user = userMessage("message-1") - const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] - const client = rootMessageClient( - [response(assistants.map((info) => ({ info, parts: [] })))], - [singleResponse(user)], - ) - const store = createServerSession(client) - store.inbox.echo(promptEcho(user.id, "text")) - - await store.sync("child") - - expect(client.requests).toHaveLength(1) - expect(client.rootRequests).toHaveLength(1) - expect(store.data.message.child).toEqual([user, ...assistants]) - }) - - test("backfills the parent of fetched assistants when another user is cached", async () => { - const unrelated = userMessage("message-0", { time: { created: 0 } }) - const user = userMessage("message-1") - const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] - const client = rootMessageClient( - [response([{ info: unrelated, parts: [] }]), response(assistants.map((info) => ({ info, parts: [] })))], - [singleResponse(user)], - ) - const store = createServerSession(client) - await store.sync("child") - - await store.sync("child", { force: true }) - - expect(client.requests).toHaveLength(2) - expect(client.rootRequests).toHaveLength(1) - expect(store.data.message.child).toEqual([unrelated, user, ...assistants]) - }) - - test("preserves cached history between an injected parent and the page boundary", async () => { - const user = userMessage("message-1") - const cached = userMessage("message-3", { time: { created: 3 } }) - const assistant = assistantMessage("message-4", user.id) - const client = rootMessageClient( - [response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }])], - [singleResponse(user)], - ) - const store = createServerSession(client) - await store.sync("child") - - await store.sync("child", { force: true }) - - expect(store.data.message.child).toEqual([user, cached, assistant]) - }) - - test("refreshes a cached parent omitted by an assistant-only replacement page", async () => { - const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } }) - const fresh = { ...stale, summary: { title: "fresh", diffs: [] } } - const stalePart = textPart(stale.id, { text: "stale" }) - const freshPart = { ...stalePart, text: "fresh" } - const assistant = assistantMessage("message-2", stale.id) - const client = rootMessageClient( - [response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }])], - [singleResponse(fresh, [freshPart])], - ) - const store = createServerSession(client) - await store.sync("child") - - await store.sync("child", { force: true }) - - expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }]) - expect(store.data.message.child).toEqual([fresh, assistant]) - expect(store.data.part[stale.id]).toEqual([freshPart]) - }) - - test("uses a parent received by SSE during the replacement load", async () => { - const pending = deferredResponse() - const user = userMessage("message-1") - const assistant = assistantMessage("message-2", user.id) - const client = rootMessageClient([pending.promise], []) - const store = createServerSession(client) - const loading = store.sync("child") - - store.apply({ type: "message.updated", properties: { info: user } }) - pending.resolve(response([{ info: assistant, parts: [] }])) - await loading - - expect(client.rootRequests).toEqual([]) - expect(store.data.message.child).toEqual([user, assistant]) - }) - - test("uses a successful retry over events received by a failed backfill attempt", async () => { - const failed = deferredResponse() - const user = userMessage("message-1") - const live = { ...user, agent: "stale" } - const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] - const client = rootMessageClient( - [response(assistants.map((info) => ({ info, parts: [] })))], - [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], - ) - const store = createServerSession(client, { retry: retryImmediately }) - const loading = store.sync("child") - await client.rootRequested(1) - - store.apply({ type: "message.updated", properties: { info: live } }) - failed.reject(new Error("retry")) - await loading - - expect(client.requests).toHaveLength(1) - expect(client.rootRequests).toHaveLength(2) - expect(store.data.message.child).toEqual([user, ...assistants]) - }) - - test("preserves newer-page events across a failed parent retry", async () => { - const failed = deferredResponse() - const user = userMessage("message-1") - const assistant = assistantMessage("message-2", user.id) - const live = { ...assistant, cost: 1 } - const client = rootMessageClient( - [response([{ info: assistant, parts: [] }])], - [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], - ) - const store = createServerSession(client, { retry: retryImmediately }) - const loading = store.sync("child") - await client.rootRequested(1) - - store.apply({ type: "message.updated", properties: { info: live } }) - failed.reject(new Error("retry")) - await loading - - expect(store.data.message.child).toEqual([user, live]) - }) - - test("preserves unrelated message events across a failed parent retry", async () => { - const failed = deferredResponse() - const user = userMessage("message-1") - const assistant = assistantMessage("message-2", user.id) - const live = userMessage("message-4", { time: { created: 4 } }) - const client = rootMessageClient( - [response([{ info: assistant, parts: [] }])], - [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], - ) - const store = createServerSession(client, { retry: retryImmediately }) - const loading = store.sync("child") - await client.rootRequested(1) - - store.apply({ type: "message.updated", properties: { info: live } }) - failed.reject(new Error("retry")) - await loading - - expect(store.data.message.child).toEqual([user, assistant, live]) - }) - - test("preserves newer-page part events across a failed parent retry", async () => { - const failed = deferredResponse() - const user = userMessage("message-1") - const assistant = assistantMessage("message-2", user.id) - const stale = textPart(assistant.id, { text: "stale" }) - const live = { ...stale, text: "live" } - const client = rootMessageClient( - [response([{ info: assistant, parts: [stale] }])], - [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], - ) - const store = createServerSession(client, { retry: retryImmediately }) - const loading = store.sync("child") - await client.rootRequested(1) - - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } }) - failed.reject(new Error("retry")) - await loading - - expect(store.data.part[assistant.id]).toEqual([live]) - }) - }) - test("merges live events into the initial page", async () => { const pending = deferredResponse() const user = userMessage("message-1") diff --git a/packages/app/src/context/servers.tsx b/packages/app/src/context/servers.tsx index 13d1da6406d..f2dc63f9b0c 100644 --- a/packages/app/src/context/servers.tsx +++ b/packages/app/src/context/servers.tsx @@ -273,7 +273,8 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex }) => { const [store, setStore, _] = persisted( { - ...Persist.global("server", ["server.v3"]), + ...Persist.global("server"), + previousKey: "server.v3", migrate: (value) => migrateCanonicalLocalServerState(value, props.canonicalLocalServer), }, createStore({ diff --git a/packages/app/src/context/tab-migration.ts b/packages/app/src/context/tab-migration.ts index 32515515570..9fb7995419d 100644 --- a/packages/app/src/context/tab-migration.ts +++ b/packages/app/src/context/tab-migration.ts @@ -1,12 +1,12 @@ import type { ServerConnection } from "./servers" import type { Tab } from "./tabs" -export function migrateTabs(value: unknown, fallback: ServerConnection.Key): Tab[] { +export function migrateTabs(value: unknown): Tab[] { if (!Array.isArray(value)) return [] return value.flatMap((tab) => { if (!tab || typeof tab !== "object") return [] - if ("server" in tab && typeof tab.server !== "string") return [] - const server = ("server" in tab ? tab.server : fallback) as ServerConnection.Key + if (!("server" in tab) || typeof tab.server !== "string") return [] + const server = tab.server as ServerConnection.Key if (tab.type === "session" && typeof tab.sessionId === "string") { return [{ type: tab.type, server, sessionId: tab.sessionId }] } diff --git a/packages/app/src/context/tabs.test.ts b/packages/app/src/context/tabs.test.ts index 86911b4e63d..3b4940d2efe 100644 --- a/packages/app/src/context/tabs.test.ts +++ b/packages/app/src/context/tabs.test.ts @@ -15,17 +15,17 @@ function sessionTab(sessionId: string): SessionTab { describe("tab migration", () => { test("drops null and malformed persisted tabs", () => { expect( - migrateTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"], server), + migrateTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"]), ).toEqual([sessionTab("a")]) }) - test("adds the fallback server to valid legacy tabs", () => { - expect(migrateTabs([{ type: "session", sessionId: "a", dirBase64: "legacy" }], server)).toEqual([sessionTab("a")]) + test("drops persisted tabs without a server", () => { + expect(migrateTabs([{ type: "session", sessionId: "a" }])).toEqual([]) }) test("replaces invalid top-level persisted data", () => { - expect(migrateTabs(null, server)).toEqual([]) - expect(migrateTabs({}, server)).toEqual([]) + expect(migrateTabs(null)).toEqual([]) + expect(migrateTabs({})).toEqual([]) }) }) diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index e256151447a..efc37bf8a3b 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -60,7 +60,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const [store, setStore, _, ready] = persisted( { ...Persist.window("tabs"), - migrate: (value: unknown) => migrateTabs(value, ServerConnection.key(servers.list[0])), + migrate: migrateTabs, }, createStore([]), ) diff --git a/packages/app/src/context/terminal.test.ts b/packages/app/src/context/terminal.test.ts index 25ef35dd413..b67473ea8af 100644 --- a/packages/app/src/context/terminal.test.ts +++ b/packages/app/src/context/terminal.test.ts @@ -1,8 +1,11 @@ import { beforeAll, describe, expect, mock, test } from "bun:test" import { ServerScope } from "@/utils/server-scope" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { Persist } from "@/utils/persist" +import type { Platform } from "./platform" let getWorkspaceTerminalCacheKey: typeof import("./terminal").getWorkspaceTerminalCacheKey -let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[] +let clearWorkspaceTerminals: typeof import("./terminal").clearWorkspaceTerminals let migrateTerminalState: (value: unknown) => unknown beforeAll(async () => { @@ -20,7 +23,7 @@ beforeAll(async () => { })) const mod = await import("./terminal") getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey - getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys + clearWorkspaceTerminals = mod.clearWorkspaceTerminals migrateTerminalState = mod.migrateTerminalState }) @@ -34,18 +37,27 @@ describe("getWorkspaceTerminalCacheKey", () => { "ssh:debian\u0000/repo\u0000__workspace__", ) }) -}) -describe("getLegacyTerminalStorageKeys", () => { - test("keeps workspace storage path when no legacy session id", () => { - expect(getLegacyTerminalStorageKeys("/repo")).toEqual(["/repo/terminal.v1"]) - }) + test("clears the current workspace terminal store", () => { + const removed: { storage?: string; key: string }[] = [] + const platform: Platform = { + platform: "desktop", + windowID: "window", + openExternal: () => undefined, + restart: async () => undefined, + notify: async () => undefined, + openDirectoryPickerDialog: async () => null, + storage: (storage) => ({ + getItem: () => null, + setItem: () => undefined, + removeItem: (key) => void removed.push({ storage, key }), + }), + } - test("includes legacy session path before workspace path", () => { - expect(getLegacyTerminalStorageKeys("/repo", "session-123")).toEqual([ - "/repo/terminal/session-123.v1", - "/repo/terminal.v1", - ]) + clearWorkspaceTerminals("C:/repo", platform) + + const target = Persist.workspace(base64Encode("C:/repo"), "terminal") + expect(removed).toEqual([{ storage: target.storage, key: target.key }]) }) }) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 8593c2b53c9..249155a64ee 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -1,7 +1,6 @@ import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js" -import { useParams } from "@solidjs/router" import { useSDK, type DirectorySDK } from "./sdk" import type { Platform } from "./platform" import { useServerSDK } from "./server-sdk" @@ -89,11 +88,6 @@ export function getWorkspaceTerminalCacheKey(dir: string, scope: ServerScopeValu return ScopedKey.from(scope, dir, WORKSPACE_KEY) } -export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) { - if (!legacySessionID) return [`${dir}/terminal.v1`] - return [`${dir}/terminal/${legacySessionID}.v1`, `${dir}/terminal.v1`] -} - type TerminalSession = ReturnType type TerminalCacheEntry = { @@ -113,48 +107,27 @@ const trimTerminal = (pty: LocalPTY) => { } } -function terminalPersistTarget(scope: ServerScopeValue, dir: string, legacy?: string[]) { - return Persist.serverWorkspace(scope, dir, "terminal", legacy) +function terminalPersistTarget(scope: ServerScopeValue, dir: string) { + return Persist.serverWorkspace(scope, dir, "terminal") } -export function clearWorkspaceTerminals( - dir: string, - sessionIDs?: string[], - platform?: Platform, - scope: ServerScopeValue = ServerScope.local, -) { - const key = getWorkspaceTerminalCacheKey(dir, scope) +export function clearWorkspaceTerminals(dir: string, platform?: Platform, scope: ServerScopeValue = ServerScope.local) { + const storageDir = base64Encode(dir) + const key = getWorkspaceTerminalCacheKey(storageDir, scope) for (const cache of caches) { const entry = cache.get(key) entry?.value.clear() } - void removePersisted(terminalPersistTarget(scope, dir), platform) - - if (scope !== ServerScope.local) return - const legacy = new Set(getLegacyTerminalStorageKeys(dir)) - for (const id of sessionIDs ?? []) { - for (const key of getLegacyTerminalStorageKeys(dir, id)) { - legacy.add(key) - } - } - for (const key of legacy) { - void removePersisted({ key }, platform) - } + void removePersisted(terminalPersistTarget(scope, storageDir), platform) } -function createWorkspaceTerminalSession( - sdk: DirectorySDK, - dir: string, - scope: ServerScopeValue, - legacySessionID?: string, -) { +function createWorkspaceTerminalSession(sdk: DirectorySDK, dir: string, scope: ServerScopeValue) { const location = { directory: sdk.directory } - const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : [] const [store, setStore, _, ready] = persisted( { - ...terminalPersistTarget(scope, dir, legacy), + ...terminalPersistTarget(scope, dir), migrate: migrateTerminalState, }, createStore<{ @@ -438,7 +411,6 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont init: () => { const sdk = useSDK() const serverSDK = useServerSDK() - const params = useParams() const cache = new Map() const scope = () => serverSDK.scope const directory = createMemo(() => base64Encode(sdk().directory)) @@ -465,7 +437,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont } } - const loadWorkspace = (dir: string, legacySessionID: string | undefined, serverScope: ServerScopeValue) => { + const loadWorkspace = (dir: string, serverScope: ServerScopeValue) => { // Terminals are workspace-scoped so tabs persist while switching sessions in the same directory. const key = getWorkspaceTerminalCacheKey(dir, serverScope) const existing = cache.get(key) @@ -476,7 +448,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont } const entry = createRoot((dispose) => ({ - value: createWorkspaceTerminalSession(sdk(), dir, serverScope, legacySessionID), + value: createWorkspaceTerminalSession(sdk(), dir, serverScope), dispose, })) @@ -485,16 +457,15 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont return entry.value } - const workspace = createMemo(() => loadWorkspace(directory(), params.id, scope())) + const workspace = createMemo(() => loadWorkspace(directory(), scope())) createEffect( on( - () => ({ dir: directory(), id: params.id, scope: scope() }), + () => ({ dir: directory(), scope: scope() }), (next, prev) => { if (!prev?.dir) return - if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return - if (next.dir === prev.dir && next.id && next.scope === prev.scope) return - loadWorkspace(prev.dir, prev.id, prev.scope).trimAll() + if (next.dir === prev.dir && next.scope === prev.scope) return + loadWorkspace(prev.dir, prev.scope).trimAll() }, { defer: true }, ), diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index 630fd5c8b31..fb5c6f892ac 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -169,7 +169,6 @@ if (root instanceof HTMLElement) { defaultServer={ServerConnection.Key.make(getDefaultUrl())} canonicalLocalServer={ServerConnection.key(server)} servers={[server]} - disableHealthCheck /> diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 69f17eecada..9a8e023299a 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -10,7 +10,7 @@ export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from export { useCommand } from "./context/command" export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./context/language" export { useWslServers } from "./wsl/context" -export { type DisplayBackend, type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform" +export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform" export { type UpdaterPlatform, type UpdaterState } from "./updater" export { type WslDistroProbe, diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 6ccca5f53bc..4185763a656 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -1,14 +1,8 @@ import { DataProvider } from "@opencode-ai/session-ui/context" -import { showToast } from "@/utils/toast" -import { base64Encode } from "@opencode-ai/core/util/encode" -import { useLocation, useNavigate, useParams } from "@solidjs/router" -import { createEffect, createMemo, createResource, onCleanup, type ParentProps, Show } from "solid-js" -import { useLanguage } from "@/context/language" +import { useNavigate, useParams } from "@solidjs/router" +import { createEffect, createResource, onCleanup, type ParentProps, Show } from "solid-js" import { LocalProvider } from "@/context/local" -import { SDKProvider } from "@/context/sdk" import { useSync } from "@/context/sync" -import { decode64 } from "@/utils/base64" -import { Schema } from "effect" import type { ServerConnection } from "@/context/servers" import { sessionHref } from "@/utils/session-route" import { useServerSync } from "@/context/server-sync" @@ -16,37 +10,21 @@ import { useServerSync } from "@/context/server-sync" export function DirectoryDataProvider( props: ParentProps<{ directory: string - draftID?: string - server?: ServerConnection.Key + server: ServerConnection.Key }>, ) { - const location = useLocation() const navigate = useNavigate() const params = useParams() const sync = useSync() const serverSync = useServerSync() - const language = useLanguage() const directory = () => props.directory - const slug = createMemo(() => base64Encode(directory())) - const href = (sessionID: string) => { - if (props.server) return sessionHref(props.server, sessionID) - return `/${slug()}/session/${sessionID}` - } + const href = (sessionID: string) => sessionHref(props.server, sessionID) const navigateToSession = async (sessionID: string) => { const session = serverSync.session await Promise.allSettled([session.lineage.resolve(sessionID), session.sync(sessionID)]) navigate(href(sessionID)) } - createEffect(() => { - // A draft lives at /new-session?draftId=… and has no directory segment to normalize. - if (props.draftID || props.server) return - const next = sync().data.path.directory - if (!next || next === directory()) return - const path = location.pathname.slice(slug().length + 1) - navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) - }) - createResource( () => params.id, (id) => serverSync.session.hydrate(id).catch(() => {}), @@ -75,51 +53,3 @@ export function DirectoryDataProvider(
) } - -export const ProjectDirString = Schema.String.pipe(Schema.brand("ProjectDirString")) -export type ProjectDirString = Schema.Schema.Type - -export function decodeDirectory(dir: string): ProjectDirString | undefined { - const decoded = decode64(dir) - if (!decoded) return - return ProjectDirString.make(decoded) -} - -export default function Layout(props: ParentProps) { - const params = useParams() - const language = useLanguage() - const navigate = useNavigate() - let invalid = "" - - const resolved = createMemo(() => { - if (!params.dir) return "" - return decodeDirectory(params.dir) ?? "" - }) - - createEffect(() => { - const dir = params.dir - if (!dir) return - if (resolved()) { - invalid = "" - return - } - if (invalid === dir) return - invalid = dir - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: language.t("directory.error.invalidUrl"), - }) - navigate("/", { replace: true }) - }) - - return ( - - {(resolved) => ( - - {props.children} - - )} - - ) -} diff --git a/packages/app/src/pages/home/home-projects-controller.tsx b/packages/app/src/pages/home/home-projects-controller.tsx index 67111e0a1fe..5128bf1c5e2 100644 --- a/packages/app/src/pages/home/home-projects-controller.tsx +++ b/packages/app/src/pages/home/home-projects-controller.tsx @@ -25,7 +25,7 @@ export function createHomeProjectsController(home: HomeController) { const serverManagement = useServerActionsController() const global = useGlobal() const [_state, setState, _, ready] = persisted( - Persist.global("home.servers", ["home.servers.v1"]), + Persist.global("home.servers"), createStore({ collapsed: {} as Record }), ) const [state] = createResource( diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index ea5436a0798..428e8628ac7 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -97,7 +97,7 @@ import { diffs as list } from "@/utils/diffs" import { Persist, persisted } from "@/utils/persist" import { extractPromptFromParts } from "@/utils/prompt" import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors" -import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" +import { requireServerKey, sessionHref } from "@/utils/session-route" import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" import { createSessionLineage } from "./session/session-lineage" @@ -506,7 +506,7 @@ export default function Page() { }) const [followup, setFollowup] = persisted( - Persist.serverWorkspace(serverSDK.scope, sdk().directory, "followup", ["followup.v1"]), + Persist.serverWorkspace(serverSDK.scope, sdk().directory, "followup"), createStore<{ items: Record failed: Record @@ -2083,11 +2083,7 @@ export default function Page() { openParent: () => { const id = controller.data.parentID() if (!id) return - navigate( - controller.identity.params.serverKey - ? sessionHref(requireServerKey(controller.identity.params.serverKey), id) - : legacySessionHref(sdk().directory, id), - ) + navigate(sessionHref(requireServerKey(controller.identity.params.serverKey), id)) }, setPromptRef: (el) => { inputRef = el diff --git a/packages/app/src/pages/session/timeline/controller.tsx b/packages/app/src/pages/session/timeline/controller.tsx index a15c75c8804..242df2d313d 100644 --- a/packages/app/src/pages/session/timeline/controller.tsx +++ b/packages/app/src/pages/session/timeline/controller.tsx @@ -13,7 +13,7 @@ import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { useTabs } from "@/context/tabs" import type { SessionController } from "@/pages/session/session-controller" -import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" +import { sessionHref } from "@/utils/session-route" import { sessionTitle } from "@/utils/session-title" import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export" import { showToast } from "@/utils/toast" @@ -142,20 +142,12 @@ export function createTimelineController(input: { const id = input.session.identity.sessionID() if (!id || pending.unshare || !shareEnabled()) return } - const href = (id: string) => - input.session.identity.params.serverKey - ? sessionHref(requireServerKey(input.session.identity.params.serverKey), id) - : legacySessionHref(sdk().directory, id) + const href = (id: string) => sessionHref(server.key, id) const navigateAfterRemoval = (id: string, parent?: string, next?: string) => { if (input.session.identity.params.id !== id) return if (parent) return navigate(href(parent)) if (next) return navigate(href(next)) - if (input.session.identity.params.serverKey) - return tabs.newDraft({ - server: requireServerKey(input.session.identity.params.serverKey), - directory: sdk().directory, - }) - navigate(`/${input.session.identity.params.dir}/session`) + return tabs.newDraft({ server: server.key, directory: sdk().directory }) } const exportSession = async (id: string) => { try { diff --git a/packages/app/src/pages/session/timeline/summary-diffs.test.ts b/packages/app/src/pages/session/timeline/summary-diffs.test.ts index 072c63e8144..7b4009be67b 100644 --- a/packages/app/src/pages/session/timeline/summary-diffs.test.ts +++ b/packages/app/src/pages/session/timeline/summary-diffs.test.ts @@ -24,7 +24,7 @@ describe("uniqueSummaryDiffs", () => { expect(result[1]).toBe(beta) }) - test("keeps the last diff per file in the legacy display order", () => { + test("keeps the last diff per file in display order", () => { const oldAlpha = diff("alpha.ts", 1) const oldBeta = diff("beta.ts", 1) const newAlpha = diff("alpha.ts", 2) diff --git a/packages/app/src/theme-preload.test.ts b/packages/app/src/theme-preload.test.ts index 00d7da23948..4dcd1d07d73 100644 --- a/packages/app/src/theme-preload.test.ts +++ b/packages/app/src/theme-preload.test.ts @@ -19,21 +19,6 @@ beforeEach(() => { }) describe("theme preload", () => { - test("migrates legacy oc-1 to oc-2 before mount", () => { - localStorage.setItem("opencode-theme-id", "oc-1") - localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;") - localStorage.setItem("opencode-theme-css-dark", "--background-base:#000;") - - run() - - expect(document.documentElement.dataset.theme).toBe("oc-2") - expect(document.documentElement.dataset.colorScheme).toBe("light") - expect(localStorage.getItem("opencode-theme-id")).toBe("oc-2") - expect(localStorage.getItem("opencode-theme-css-light")).toBeNull() - expect(localStorage.getItem("opencode-theme-css-dark")).toBeNull() - expect(document.getElementById("oc-theme-preload")).toBeNull() - }) - test("keeps cached css for non-default themes", () => { localStorage.setItem("opencode-theme-id", "nightowl") localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;") diff --git a/packages/app/src/utils/persist.test.ts b/packages/app/src/utils/persist.test.ts index dce4a371700..28b9fe98eff 100644 --- a/packages/app/src/utils/persist.test.ts +++ b/packages/app/src/utils/persist.test.ts @@ -120,50 +120,29 @@ describe("persist localStorage resilience", () => { expect(/[:\\/]/.test(result)).toBeFalse() }) - test("workspace target keeps raw path storage as legacy fallback", () => { + test("workspace target keeps raw path storage as a relocation source", () => { const target = Persist.workspace("C:\\Users\\foo", "vcs") expect(target.storage).toBe(persistTesting.workspaceStorage("C:/Users/foo")) - expect(target.legacyStorageNames).toEqual([persistTesting.workspaceStorage("C:\\Users\\foo")]) + expect(target.workspaceStorageAliases).toEqual([persistTesting.workspaceStorage("C:\\Users\\foo")]) }) - test("workspace target keeps backslash storage as fallback for normalized Windows paths", () => { + test("workspace target keeps a backslash alias for normalized Windows paths", () => { const target = Persist.workspace("C:/Users/foo", "vcs") expect(target.storage).toBe(persistTesting.workspaceStorage("C:/Users/foo")) - expect(target.legacyStorageNames).toEqual([persistTesting.workspaceStorage("C:\\Users\\foo")]) + expect(target.workspaceStorageAliases).toEqual([persistTesting.workspaceStorage("C:\\Users\\foo")]) }) - test("migrates direct legacy keys into scoped storage", () => { - storage.setItem("legacy.workspace", '{"value":2}') - const target = Persist.workspace("C:/Users/foo", "demo", ["legacy.workspace"]) - const current = persistTesting.localStorageWithPrefix(target.storage!) - const legacyStore = persistTesting.localStorageDirect() - - const result = persistTesting.migrateLegacy({ - current, - legacyStore, - stores: [], - keys: target["legacy"]!, - key: target.key, - defaults: { value: 1 }, - }) - - expect(result).toBe('{"value":2}') - expect(storage.getItem(`${target.storage}:${target.key}`)).toBe('{"value":2}') - expect(legacyStore.getItem("legacy.workspace")).toBeNull() - expect(storage.getItem("legacy.workspace")).toBeNull() - }) - - test("removes legacy workspace storage when removing persisted target", () => { + test("removes workspace storage aliases when removing persisted target", () => { const target = Persist.workspace("C:\\Users\\foo", "terminal") storage.setItem(`${target.storage}:${target.key}`, '{"value":1}') - storage.setItem(`${target.legacyStorageNames![0]}:${target.key}`, '{"value":2}') + storage.setItem(`${target.workspaceStorageAliases![0]}:${target.key}`, '{"value":2}') removePersisted(target) expect(storage.getItem(`${target.storage}:${target.key}`)).toBeNull() - expect(storage.getItem(`${target.legacyStorageNames![0]}:${target.key}`)).toBeNull() + expect(storage.getItem(`${target.workspaceStorageAliases![0]}:${target.key}`)).toBeNull() }) test("draft target isolates storage per draft and namespaces keys", () => { @@ -193,8 +172,8 @@ describe("persist localStorage resilience", () => { expect(windows.storage).not.toBe(local.storage) expect(debian.storage).not.toBe(local.storage) expect(debian.storage).not.toBe(windows.storage) - expect(windows.legacyStorageNames).toBeUndefined() - expect(debian.legacyStorageNames).toBeUndefined() + expect(windows.workspaceStorageAliases).toBeUndefined() + expect(debian.workspaceStorageAliases).toBeUndefined() }) test("server global target preserves local key and isolates remote keys", () => { diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 0d28c3f3c75..301177b62db 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -18,13 +18,12 @@ type PersistTarget = { draft?: boolean storage?: string scope?: "window" - legacyStorageNames?: string[] + workspaceStorageAliases?: string[] + previousKey?: string key: string - legacy?: string[] migrate?: (value: unknown) => unknown } -const LEGACY_STORAGE = "default.dat" const GLOBAL_STORAGE = "opencode.global.dat" const WINDOW_STORAGE = "opencode.window" const LOCAL_PREFIX = "opencode." @@ -231,45 +230,28 @@ function readCurrent(input: { return next } -function migrateLegacy(input: { +function relocateStoredValue(input: { current: SyncStorage - legacyStore?: SyncStorage - stores: SyncStorage[] - keys: string[] + sources: { storage: SyncStorage; key?: string }[] key: string defaults: unknown migrate?: (value: unknown) => unknown }) { - for (const store of input.stores) { - const raw = store.getItem(input.key) + for (const source of input.sources) { + const key = source.key ?? input.key + const raw = source.storage.getItem(key) if (raw === null) continue const next = normalize(input.defaults, raw, input.migrate) if (next === undefined) { - store.removeItem(input.key) + source.storage.removeItem(key) continue } input.current.setItem(input.key, next) - store.removeItem(input.key) + if (input.current.getItem(input.key) !== next) return null + source.storage.removeItem(key) return next } - - if (!input.legacyStore) return null - - for (const key of input.keys) { - const raw = input.legacyStore.getItem(key) - if (raw === null) continue - - const next = normalize(input.defaults, raw, input.migrate) - if (next === undefined) { - input.legacyStore.removeItem(key) - continue - } - input.current.setItem(input.key, next) - input.legacyStore.removeItem(key) - return next - } - return null } @@ -304,45 +286,27 @@ function toAsyncStorage(storage: SyncStorage | AsyncStorage): AsyncStorage { } } -async function migrateLegacyAsync(input: { +async function relocateStoredValueAsync(input: { current: AsyncStorage - legacyStore?: AsyncStorage - stores: AsyncStorage[] - keys: string[] + sources: { storage: AsyncStorage; key?: string }[] key: string defaults: unknown migrate?: (value: unknown) => unknown }) { - for (const store of input.stores) { - const raw = await store.getItem(input.key) + for (const source of input.sources) { + const key = source.key ?? input.key + const raw = await source.storage.getItem(key) if (raw === null) continue const next = normalize(input.defaults, raw, input.migrate) if (next === undefined) { - await removeAsync(store, input.key) + await removeAsync(source.storage, key) continue } await input.current.setItem(input.key, next) - await store.removeItem(input.key) + await source.storage.removeItem(key) return next } - - if (!input.legacyStore) return null - - for (const key of input.keys) { - const raw = await input.legacyStore.getItem(key) - if (raw === null) continue - - const next = normalize(input.defaults, raw, input.migrate) - if (next === undefined) { - await removeAsync(input.legacyStore, key) - continue - } - await input.current.setItem(input.key, next) - await input.legacyStore.removeItem(key) - return next - } - return null } @@ -363,7 +327,7 @@ function windowStorage(windowID: string) { return `${WINDOW_STORAGE}.${safe}.dat` } -function legacyWorkspaceStorage(dir: string) { +function workspaceStorageAliases(dir: string) { const storage = workspaceStorage(pathKey(dir)) const result = new Set() const raw = workspaceStorage(dir) @@ -380,9 +344,9 @@ function legacyWorkspaceStorage(dir: string) { return [...result] } -function serverWorkspaceTarget(scope: ServerScopeValue, dir: string, key: string, legacy?: string[]): PersistTarget { +function serverWorkspaceTarget(scope: ServerScopeValue, dir: string, key: string): PersistTarget { if (scope !== ServerScope.local) return { storage: workspaceStorage(ScopedKey.from(scope, pathKey(dir))), key } - return { storage: workspaceStorage(pathKey(dir)), legacyStorageNames: legacyWorkspaceStorage(dir), key, legacy } + return { storage: workspaceStorage(pathKey(dir)), workspaceStorageAliases: workspaceStorageAliases(dir), key } } function localStorageWithPrefix(prefix: string): SyncStorage { @@ -481,7 +445,6 @@ export function draftPersistedKeys() { export const PersistTesting = { localStorageDirect, localStorageWithPrefix, - migrateLegacy, normalize, resolveTarget, windowStorage, @@ -489,38 +452,38 @@ export const PersistTesting = { } export const Persist = { - global(key: string, legacy?: string[]): PersistTarget { - return { storage: GLOBAL_STORAGE, key, legacy } + global(key: string): PersistTarget { + return { storage: GLOBAL_STORAGE, key } }, - window(key: string, legacy?: string[]): PersistTarget { - return { scope: "window", key, legacy } + window(key: string): PersistTarget { + return { scope: "window", key } }, - draft(draftID: string, key: string, legacy?: string[]): PersistTarget { - return { storage: draftStorage(draftID), key: `draft:${key}`, legacy } + draft(draftID: string, key: string): PersistTarget { + return { storage: draftStorage(draftID), key: `draft:${key}` } }, - serverGlobal(scope: ServerScopeValue, key: string, legacy?: string[]): PersistTarget { - if (scope === ServerScope.local) return Persist.global(key, legacy) + serverGlobal(scope: ServerScopeValue, key: string): PersistTarget { + if (scope === ServerScope.local) return Persist.global(key) return { storage: GLOBAL_STORAGE, key: ScopedKey.from(scope, key) } }, - workspace(dir: string, key: string, legacy?: string[]): PersistTarget { - return serverWorkspaceTarget(ServerScope.local, dir, `workspace:${key}`, legacy) + workspace(dir: string, key: string): PersistTarget { + return serverWorkspaceTarget(ServerScope.local, dir, `workspace:${key}`) }, - serverWorkspace(scope: ServerScopeValue, dir: string, key: string, legacy?: string[]): PersistTarget { - return serverWorkspaceTarget(scope, dir, `workspace:${key}`, legacy) + serverWorkspace(scope: ServerScopeValue, dir: string, key: string): PersistTarget { + return serverWorkspaceTarget(scope, dir, `workspace:${key}`) }, - session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget { - return serverWorkspaceTarget(ServerScope.local, dir, `session:${session}:${key}`, legacy) + session(dir: string, session: string, key: string): PersistTarget { + return serverWorkspaceTarget(ServerScope.local, dir, `session:${session}:${key}`) }, - serverSession(scope: ServerScopeValue, dir: string, session: string, key: string, legacy?: string[]): PersistTarget { - return serverWorkspaceTarget(scope, dir, `session:${session}:${key}`, legacy) + serverSession(scope: ServerScopeValue, dir: string, session: string, key: string): PersistTarget { + return serverWorkspaceTarget(scope, dir, `session:${session}:${key}`) }, - scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget { - if (session) return Persist.session(dir, session, key, legacy) - return Persist.workspace(dir, key, legacy) + scoped(dir: string, session: string | undefined, key: string): PersistTarget { + if (session) return Persist.session(dir, session, key) + return Persist.workspace(dir, key) }, - serverScoped(scope: ServerScopeValue, dir: string, session: string | undefined, key: string, legacy?: string[]) { - if (session) return Persist.serverSession(scope, dir, session, key, legacy) - return Persist.serverWorkspace(scope, dir, key, legacy) + serverScoped(scope: ServerScopeValue, dir: string, session: string | undefined, key: string) { + if (session) return Persist.serverSession(scope, dir, session, key) + return Persist.serverWorkspace(scope, dir, key) }, prompt(target: PersistTarget): PersistTarget { return { ...target, draft: true } @@ -529,8 +492,8 @@ export const Persist = { function resolveTarget(target: PersistTarget, platform: Platform): PersistTarget { if (target.scope !== "window") return target - if (platform.platform === "desktop" && !platform.windowID) return { ...target, storage: GLOBAL_STORAGE } - const windowID = platform.platform === "desktop" ? (platform.windowID ?? "browser") : "browser" + const windowID = platform.platform === "desktop" ? platform.windowID : "browser" + if (!windowID) throw new Error("Desktop window ID is required for window-scoped storage") return { ...target, storage: windowStorage(windowID), @@ -538,7 +501,7 @@ function resolveTarget(target: PersistTarget, platform: Platform): PersistTarget } export function removePersisted( - target: { draft?: boolean; storage?: string; legacyStorageNames?: string[]; key: string }, + target: { draft?: boolean; storage?: string; workspaceStorageAliases?: string[]; key: string }, platform?: Platform, ) { if (target.draft && platform?.draftStore) { @@ -548,7 +511,7 @@ export function removePersisted( if (isDesktop) { void platform.storage?.(target.storage)?.removeItem(target.key) - for (const storage of target.legacyStorageNames ?? []) { + for (const storage of target.workspaceStorageAliases ?? []) { void platform.storage?.(storage)?.removeItem(target.key) } return @@ -560,7 +523,7 @@ export function removePersisted( } localStorageWithPrefix(target.storage).removeItem(target.key) - for (const storage of target.legacyStorageNames ?? []) { + for (const storage of target.workspaceStorageAliases ?? []) { localStorageWithPrefix(storage).removeItem(target.key) } } @@ -574,8 +537,6 @@ export function persisted( const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform) const defaults = snapshot(store[0]) - const legacy = config["legacy"] ?? [] - const isDesktop = platform.platform === "desktop" && !!platform.storage const draft = config.draft ? platform.draftStore : undefined @@ -593,29 +554,23 @@ export function persisted( return localStorageWithPrefix(config.storage) })() - const legacyStorage = (() => { - if (!isDesktop) return localStorageDirect() - if (!config.storage) return platform.storage?.() - return platform.storage?.(LEGACY_STORAGE) - })() - - const legacyStorageNames = config.legacyStorageNames ?? [] + const workspaceAliases = config.workspaceStorageAliases ?? [] const storage = (() => { if (!isDesktop && !draft) { const current = currentStorage as SyncStorage - const legacyStore = legacyStorage as SyncStorage - const legacyStores = legacyStorageNames.map(localStorageWithPrefix) + const sources = [ + ...workspaceAliases.map((storage) => ({ storage: localStorageWithPrefix(storage) })), + ...(config.previousKey ? [{ storage: localStorageDirect(), key: config.previousKey }] : []), + ] const api: SyncStorage = { getItem: (key) => { const value = readCurrent({ storage: current, key, defaults, migrate: config.migrate }) if (value !== undefined) return value - return migrateLegacy({ + return relocateStoredValue({ current, - legacyStore, - stores: legacyStores, - keys: legacy, + sources, key, defaults, migrate: config.migrate, @@ -633,38 +588,39 @@ export function persisted( } const current = currentStorage as AsyncStorage - const legacyStore = legacyStorage as AsyncStorage | undefined - const oldCurrent = draft + const previousDraftStorage = draft ? isDesktop ? platform.storage?.(config.storage) : config.storage ? localStorageWithPrefix(config.storage) : localStorageDirect() : undefined - const legacyStores = [ - oldCurrent, - ...legacyStorageNames.map((name) => (isDesktop ? platform.storage?.(name) : localStorageWithPrefix(name))), + const previousStorage = config.previousKey ? (isDesktop ? platform.storage?.() : localStorageDirect()) : undefined + const relocationSources = [ + previousDraftStorage ? { storage: previousDraftStorage } : undefined, + ...workspaceAliases.map((name) => ({ + storage: isDesktop ? platform.storage?.(name) : localStorageWithPrefix(name), + })), + previousStorage && config.previousKey ? { storage: previousStorage, key: config.previousKey } : undefined, ] - .filter((x) => !!x) - .map(toAsyncStorage) + .filter((source): source is { storage: SyncStorage | AsyncStorage; key?: string } => !!source?.storage) + .map((source) => ({ ...source, storage: toAsyncStorage(source.storage) })) let draftLatest: string | undefined const api: AsyncStorage = { getItem: async (key) => { const value = await readCurrentAsync({ storage: current, key, defaults, migrate: config.migrate }) if (value !== undefined) return value - const migrated = await migrateLegacyAsync({ + const relocated = await relocateStoredValueAsync({ current, - legacyStore, - stores: legacyStores, - keys: legacy, + sources: relocationSources, key, defaults, migrate: config.migrate, }) if (draftLatest === undefined) { - if (draft && migrated !== null) return (await current.getItem(key)) ?? migrated - return migrated + if (draft && relocated !== null) return (await current.getItem(key)) ?? relocated + return relocated } await current.setItem(key, draftLatest) return draftLatest diff --git a/packages/app/src/utils/server-scope.test.ts b/packages/app/src/utils/server-scope.test.ts index 67d43b7abbb..2cefda76a6e 100644 --- a/packages/app/src/utils/server-scope.test.ts +++ b/packages/app/src/utils/server-scope.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { ScopedKey, ServerScope, SessionRouteKey, SessionStateKey, migrateLegacySessionStateKeys } from "./server-scope" +import { ScopedKey, ServerScope, SessionRouteKey, SessionStateKey } from "./server-scope" describe("ServerScope", () => { test("uses a stable local scope for the canonical sidecar", () => { @@ -32,25 +32,16 @@ describe("SessionStateKey", () => { ) }) - test("extracts route keys from scoped and legacy state keys", () => { - expect(String(SessionStateKey.route("cmVwbw/session-1"))).toBe("cmVwbw/session-1") + test("extracts route keys from scoped state keys", () => { expect(String(SessionStateKey.route("local\0cmVwbw/session-1"))).toBe("cmVwbw/session-1") expect(String(SessionStateKey.route("https://debian.example\0cmVwbw/session-1"))).toBe("cmVwbw/session-1") }) -}) -describe("migrateLegacySessionStateKeys", () => { - test("copies legacy route keys into local scope without overwriting scoped state", () => { - expect( - migrateLegacySessionStateKeys({ - "cmVwbw/session-1": { active: "legacy" }, - "local\0cmVwbw/session-1": { active: "scoped" }, - "https://debian.example\0cmVwbw/session-1": { active: "remote" }, - }), - ).toEqual({ - "local\0cmVwbw/session-1": { active: "scoped" }, - "https://debian.example\0cmVwbw/session-1": { active: "remote" }, - }) + test("rejects unscoped state keys", () => { + expect(SessionStateKey.is("cmVwbw/session-1")).toBe(false) + expect(SessionStateKey.is("local\0cmVwbw/session-1")).toBe(true) + expect(() => SessionStateKey.route("cmVwbw/session-1")).toThrow("Session state key must include server scope") + expect(() => SessionStateKey.scope("cmVwbw/session-1")).toThrow("Session state key must include server scope") }) test("rejects invalid identity fragments", () => { diff --git a/packages/app/src/utils/server-scope.ts b/packages/app/src/utils/server-scope.ts index 07d8507ecfb..04fa53fb0fa 100644 --- a/packages/app/src/utils/server-scope.ts +++ b/packages/app/src/utils/server-scope.ts @@ -30,22 +30,23 @@ export const SessionRouteKey = { fromRoute(dir: string | undefined, sessionID?: string) { return fragment("Session route", `${dir ?? ""}${sessionID ? "/" + sessionID : ""}`) as SessionRouteKey }, - fromLegacy(key: string) { - return fragment("Legacy session route", key) as SessionRouteKey - }, } export const SessionStateKey = { + is(key: string): key is SessionStateKey { + return key.includes(separator) + }, from(scope: ServerScope, route: SessionRouteKey) { return compose(scope, [route]) as SessionStateKey }, route(key: string) { const split = key.lastIndexOf(separator) - return SessionRouteKey.fromLegacy(split === -1 ? key : key.slice(split + 1)) + if (split === -1) throw new Error("Session state key must include server scope") + return fragment("Session route", key.slice(split + 1)) as SessionRouteKey }, scope(key: string) { const split = key.indexOf(separator) - if (split === -1) return ServerScope.local + if (split === -1) throw new Error("Session state key must include server scope") return fragment("Stored server scope", key.slice(0, split)) as ServerScope }, } @@ -58,16 +59,3 @@ export const ScopedKey = { return `${ScopedKey.from(scope, ...parts)}${separator}` }, } - -export function migrateLegacySessionStateKeys(value: unknown) { - if (!value || typeof value !== "object" || Array.isArray(value)) return value - const entries = Object.entries(value) - if (entries.every(([key]) => key.includes(separator))) return value - const scoped = Object.fromEntries(entries.filter(([key]) => key.includes(separator))) - for (const [key, item] of entries) { - if (key.includes(separator)) continue - const next = SessionStateKey.from(ServerScope.local, SessionRouteKey.fromLegacy(key)) - if (!(next in scoped)) scoped[next] = item - } - return scoped -} diff --git a/packages/app/src/utils/session-message.test.ts b/packages/app/src/utils/session-message.test.ts index a69c414e168..91a87b4b002 100644 --- a/packages/app/src/utils/session-message.test.ts +++ b/packages/app/src/utils/session-message.test.ts @@ -3,7 +3,7 @@ import type { SessionMessageInfo } from "@opencode-ai/client/promise" import { normalizeSessionMessages } from "./session-message" describe("normalizeSessionMessages", () => { - test("projects current turns into stable legacy rendering records", () => { + test("projects current turns into stable timeline rendering records", () => { const source = [ { id: "msg_1", type: "agent-switched", agent: "build", time: { created: 1 } }, { @@ -153,7 +153,7 @@ describe("normalizeSessionMessages", () => { ]) }) - test("adapts current edit fields for the legacy edit renderer", () => { + test("adapts current edit fields for the edit renderer", () => { const source = [ { id: "msg_user", type: "user", text: "edit it", time: { created: 1 } }, { diff --git a/packages/app/src/utils/session-route.test.ts b/packages/app/src/utils/session-route.test.ts index 576a27d9608..101ae434678 100644 --- a/packages/app/src/utils/session-route.test.ts +++ b/packages/app/src/utils/session-route.test.ts @@ -1,31 +1,8 @@ import { describe, expect, test } from "bun:test" import { ServerConnection } from "@/context/servers" -import { legacySessionHref, legacySessionServer, requireServerKey, rootSession, sessionHref } from "./session-route" +import { requireServerKey, rootSession, sessionHref } from "./session-route" describe("session routes", () => { - test("uses the unique persisted server for a legacy session route", () => { - expect( - legacySessionServer( - [{ type: "session", server: ServerConnection.Key.make("server-b"), sessionId: "session-1" }], - "session-1", - ServerConnection.Key.make("server-a"), - ), - ).toBe(ServerConnection.Key.make("server-b")) - }) - - test("prefers the active server when a legacy session ID is ambiguous", () => { - expect( - legacySessionServer( - [ - { type: "session", server: ServerConnection.Key.make("server-a"), sessionId: "session-1" }, - { type: "session", server: ServerConnection.Key.make("server-b"), sessionId: "session-1" }, - ], - "session-1", - ServerConnection.Key.make("server-b"), - ), - ).toBe(ServerConnection.Key.make("server-b")) - }) - test("builds and decodes a server-keyed session route", () => { const server = ServerConnection.Key.make("https://example.com:4096") const href = sessionHref(server, "session-1") @@ -38,12 +15,6 @@ describe("session routes", () => { expect(() => requireServerKey("not-base64")).toThrow("Invalid server route") }) - test("builds the legacy directory-keyed route", () => { - expect(legacySessionHref("/Users/example/project", "session-1")).toBe( - "/L1VzZXJzL2V4YW1wbGUvcHJvamVjdA/session/session-1", - ) - }) - test("resolves the root session", async () => { const sessions: Record = { child: { id: "child", parentID: "parent" }, diff --git a/packages/app/src/utils/session-route.ts b/packages/app/src/utils/session-route.ts index 12e1226f512..9e60c315ed1 100644 --- a/packages/app/src/utils/session-route.ts +++ b/packages/app/src/utils/session-route.ts @@ -6,25 +6,12 @@ export function sessionHref(server: ServerConnection.Key, sessionID: string) { return `/server/${base64Encode(server)}/session/${sessionID}` } -export function legacySessionHref(directory: string, sessionID: string) { - return `/${base64Encode(directory)}/session/${sessionID}` -} - export function requireServerKey(segment: string | undefined) { const key = decode64(segment) if (!key || base64Encode(key) !== segment) throw new Error("Invalid server route") return ServerConnection.Key.make(key) } -export function legacySessionServer( - tabs: readonly { type: "session"; server: ServerConnection.Key; sessionId: string }[], - sessionID: string, - active: ServerConnection.Key, -) { - const matches = tabs.filter((tab) => tab.sessionId === sessionID) - return matches.find((tab) => tab.server === active)?.server ?? (matches.length === 1 ? matches[0]?.server : active) -} - type SessionParent = { id: string; parentID?: string } export async function rootSession(session: T, get: (sessionID: string) => Promise) { diff --git a/packages/app/test-browser/prompt-persistence.test.ts b/packages/app/test-browser/prompt-persistence.test.ts index 7c3d28226f4..4448abd3347 100644 --- a/packages/app/test-browser/prompt-persistence.test.ts +++ b/packages/app/test-browser/prompt-persistence.test.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from "bun:test" import type { AsyncStorage } from "@solid-primitives/storage" import { createEffect, createRoot } from "solid-js" +import { createStore } from "solid-js/store" import type { Platform } from "@/context/platform" import { createPromptReady, createPromptSession } from "@/context/prompt-state" import { ServerScope } from "@/utils/server-scope" import { createDraftStore } from "@/utils/draft-store" +import { Persist, persisted } from "@/utils/persist" let read: ((value: string | null) => void) | undefined @@ -32,6 +34,20 @@ const platform: Platform = { } describe("prompt persistence", () => { + test("relocates a previous V2 key into canonical storage", () => { + localStorage.setItem("server.v3", JSON.stringify({ list: ["https://example.com"] })) + + const [state] = persisted( + { ...Persist.global("server"), previousKey: "server.v3" }, + createStore({ list: [] as string[] }), + platform, + ) + + expect(state.list).toEqual(["https://example.com"]) + expect(localStorage.getItem("opencode.global.dat:server")).toBe(JSON.stringify({ list: ["https://example.com"] })) + expect(localStorage.getItem("server.v3")).toBeNull() + }) + test("waits for an async draft to hydrate before reporting ready", async () => { await new Promise((resolve, reject) => { createRoot((dispose) => { @@ -63,9 +79,70 @@ describe("prompt persistence", () => { }) }) }) + + test("relocates a current prompt into the draft store", async () => { + const documents = new Map() + const store = createDraftStore({ + get: async (key) => documents.get(key) ?? null, + set: async (key, value) => void documents.set(key, value), + remove: async (key) => void documents.delete(key), + putBlob: async () => "blob", + getBlob: async () => null, + }) + const target = Persist.draft("draft-relocate", "prompt") + const key = `${target.storage}:${target.key}` + localStorage.setItem( + key, + JSON.stringify({ + prompt: [{ type: "text", content: "relocated draft", start: 0, end: 15 }], + cursor: 15, + context: { items: [] }, + }), + ) + + const session = createPromptSession(ServerScope.local, { draftID: "draft-relocate" }, undefined, { + ...platform, + draftStore: store, + }) + await session.ready.promise + + expect(session.current()[0]).toMatchObject({ type: "text", content: "relocated draft" }) + expect(documents.get(key)).toContain("relocated draft") + expect(localStorage.getItem(key)).toBeNull() + }) + + test("relocates a previous V2 prompt key into the draft store", async () => { + const documents = new Map() + const store = createDraftStore({ + get: async (key) => documents.get(key) ?? null, + set: async (key, value) => void documents.set(key, value), + remove: async (key) => void documents.delete(key), + putBlob: async () => "blob", + getBlob: async () => null, + }) + const dir = "encoded-directory" + const oldKey = `${dir}/prompt.v2` + const target = Persist.prompt(Persist.serverScoped(ServerScope.local, dir, undefined, "prompt")) + const key = `${target.storage}:${target.key}` + localStorage.setItem( + oldKey, + JSON.stringify({ + prompt: [{ type: "text", content: "previous V2 draft", start: 0, end: 17 }], + cursor: 17, + context: { items: [] }, + }), + ) + + const session = createPromptSession(ServerScope.local, { dir }, undefined, { ...platform, draftStore: store }) + await session.ready.promise + + expect(session.current()[0]).toMatchObject({ type: "text", content: "previous V2 draft" }) + expect(documents.get(key)).toContain("previous V2 draft") + expect(localStorage.getItem(oldKey)).toBeNull() + }) }) -test("moves legacy image data URLs into blobs and hydrates object URLs", async () => { +test("moves image data URLs into blobs and hydrates object URLs", async () => { const documents = new Map() const blobs = new Map() const store = createDraftStore({ diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 9cdd5be3c8b..29eb7509495 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -19,9 +19,8 @@ import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as import { createMenu } from "./menu" import { finishFirstLaunchOnboarding, - initializeOldLayoutEligibility, + initializeFirstLaunchOnboarding, isFirstLaunchOnboardingPending, - isOldLayoutEligible, } from "./onboarding" import { getDefaultServerUrl, preferAppEnv, setDefaultServerUrl } from "./server" import { registerUpdaterIpc, setupAutoUpdater, showUpdaterDialog } from "./updater" @@ -36,7 +35,6 @@ import { restoreMainWindows, } from "./windows" import { registerWslIpcHandlers } from "./wsl/ipc" -import { migrate } from "./migrate" import { cleanupStoreFiles } from "./store-cleanup" import { startBackgroundCli } from "./background-cli" import { setNativeTranslations } from "./native-translations" @@ -127,7 +125,7 @@ const main = Effect.gen(function* () { onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId), ) if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session")) - initializeOldLayoutEligibility(app.getPath("userData")) + initializeFirstLaunchOnboarding(app.getPath("userData")) logger = initLogging() initCrashReporter() @@ -218,7 +216,6 @@ const main = Effect.gen(function* () { yield* Effect.promise(() => app.whenReady()) - if (!TEST_ONBOARDING) migrate() yield* Effect.promise(() => cleanupStoreFiles(app.getPath("userData"))).pipe( Effect.tap((result) => Effect.sync(() => { @@ -245,7 +242,6 @@ const main = Effect.gen(function* () { relaunch, } registerIpcHandlers({ - killSidecar: () => undefined, relaunch, awaitInitialization: Effect.fnUntraced( function* () { @@ -261,9 +257,6 @@ const main = Effect.gen(function* () { setDefaultServerUrl: (url) => setDefaultServerUrl(url), isFirstLaunchOnboardingPending, finishFirstLaunchOnboarding, - isOldLayoutEligible, - getDisplayBackend: async () => null, - setDisplayBackend: async () => undefined, checkAppExists: (appName) => checkAppExists(appName), resolveAppPath: async (appName) => resolveAppPath(appName), showUpdater: () => showUpdaterDialog(updater), diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 6d769741b99..6fd583c6341 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -31,7 +31,6 @@ const pickerFilters = (ext?: string[]) => { const pickedFiles = createPickedFileAuthorizations() type Deps = { - killSidecar: () => Promise | void relaunch: () => void awaitInitialization: () => Promise consumeInitialDeepLinks: () => Promise | string[] @@ -39,9 +38,6 @@ type Deps = { setDefaultServerUrl: (url: string | null) => Promise | void isFirstLaunchOnboardingPending: () => Promise | boolean finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise | string | null - isOldLayoutEligible: () => Promise | boolean - getDisplayBackend: () => Promise - setDisplayBackend: (backend: string | null) => Promise | void checkAppExists: (appName: string) => Promise | boolean resolveAppPath: (appName: string) => Promise showUpdater: () => Promise | void @@ -57,7 +53,6 @@ export function registerIpcHandlers(deps: Deps) { app.once("will-quit", () => drafts.close()) app.on("browser-window-created", (_event, win) => win.on("session-end", () => drafts.flush())) - ipcMain.handle("kill-sidecar", () => deps.killSidecar()) ipcMain.handle("await-initialization", () => deps.awaitInitialization()) ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks()) ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl()) @@ -68,11 +63,6 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle("finish-first-launch-onboarding", (_event: IpcMainInvokeEvent, createDefaultProject: boolean) => deps.finishFirstLaunchOnboarding(createDefaultProject), ) - ipcMain.handle("is-old-layout-eligible", () => deps.isOldLayoutEligible()) - ipcMain.handle("get-display-backend", () => deps.getDisplayBackend()) - ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) => - deps.setDisplayBackend(backend), - ) ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName)) ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName)) ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color)) diff --git a/packages/desktop/src/main/migrate.ts b/packages/desktop/src/main/migrate.ts deleted file mode 100644 index 70e3dc9c750..00000000000 --- a/packages/desktop/src/main/migrate.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { app } from "electron" -import log from "electron-log/main.js" -import { existsSync, readdirSync, readFileSync } from "node:fs" -import { homedir } from "node:os" -import { join } from "node:path" -import { CHANNEL } from "./constants" -import { getStore } from "./store" - -const TAURI_MIGRATED_KEY = "tauriMigrated" - -// Resolve the directory where Tauri stored its .dat files for the given app identifier. -// Mirrors Tauri's AppLocalData / AppData resolution per OS. -function tauriDir(id: string) { - switch (process.platform) { - case "darwin": - return join(homedir(), "Library", "Application Support", id) - case "win32": - return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), id) - default: - return join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), id) - } -} - -// The Tauri app identifier changes between dev/beta/prod builds. -const TAURI_APP_IDS: Record = { - dev: "ai.opencode.desktop.dev", - beta: "ai.opencode.desktop.beta", - prod: "ai.opencode.desktop", -} -function tauriAppId() { - return app.isPackaged ? TAURI_APP_IDS[CHANNEL] : "ai.opencode.desktop.dev" -} - -// Migrate a single Tauri .dat file into the corresponding electron-store. -// `opencode.settings.dat` is special: it maps to the `opencode.settings` store -// (the electron-store name without the `.dat` extension). All other .dat files -// keep their full filename as the electron-store name so they match what the -// renderer already passes via IPC (e.g. `"default.dat"`, `"opencode.global.dat"`). -function migrateFile(datPath: string, filename: string) { - let data: Record - try { - data = JSON.parse(readFileSync(datPath, "utf-8")) - } catch (err) { - log.warn("tauri migration: failed to parse", filename, err) - return - } - - // opencode.settings.dat → the electron settings store ("opencode.settings"). - // All other .dat files keep their full filename as the store name so they match - // what the renderer passes via IPC (e.g. "default.dat", "opencode.global.dat"). - const storeName = filename === "opencode.settings.dat" ? "opencode.settings" : filename - const target = getStore(storeName) - const migrated: string[] = [] - const skipped: string[] = [] - - for (const [key, value] of Object.entries(data)) { - // Don't overwrite values the user has already set in the Electron app. - if (target.has(key)) { - skipped.push(key) - continue - } - target.set(key, value) - migrated.push(key) - } - - log.log("tauri migration: migrated", filename, "→", storeName, { migrated, skipped }) -} - -export function migrate() { - if (getStore().get(TAURI_MIGRATED_KEY)) { - log.log("tauri migration: already done, skipping") - return - } - - const dir = tauriDir(tauriAppId()) - log.log("tauri migration: starting", { dir }) - - if (!existsSync(dir)) { - log.log("tauri migration: no tauri data directory found, nothing to migrate") - getStore().set(TAURI_MIGRATED_KEY, true) - return - } - - for (const filename of readdirSync(dir)) { - if (!filename.endsWith(".dat")) continue - migrateFile(join(dir, filename), filename) - } - - log.log("tauri migration: complete") - getStore().set(TAURI_MIGRATED_KEY, true) -} diff --git a/packages/desktop/src/main/onboarding.ts b/packages/desktop/src/main/onboarding.ts index 2123551db4f..10c2ca79084 100644 --- a/packages/desktop/src/main/onboarding.ts +++ b/packages/desktop/src/main/onboarding.ts @@ -3,25 +3,21 @@ import { mkdir } from "node:fs/promises" import { join } from "node:path" import { app } from "electron" import { getStore } from "./store" -import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, OLD_LAYOUT_ELIGIBLE_KEY } from "./store-keys" +import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY } from "./store-keys" import { write as writeLog } from "./logging" import { hasExistingAppState } from "./install-state" const DEFAULT_PROJECT_DIR = "Default Project" -export function initializeOldLayoutEligibility(userDataPath: string) { +export function initializeFirstLaunchOnboarding(userDataPath: string) { const entries = existsSync(userDataPath) ? readdirSync(userDataPath, { withFileTypes: true }) : [] const store = getStore() - const current = store.get(OLD_LAYOUT_ELIGIBLE_KEY) + const current = store.get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY) if (typeof current === "boolean") return current - const eligible = hasExistingAppState(entries) - store.set(OLD_LAYOUT_ELIGIBLE_KEY, eligible) - return eligible -} - -export function isOldLayoutEligible() { - return getStore().get(OLD_LAYOUT_ELIGIBLE_KEY) === true + const complete = hasExistingAppState(entries) + store.set(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, complete) + return complete } export function isFirstLaunchOnboardingPending() { diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index 0242d38806f..98179d41e0b 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -30,28 +30,20 @@ export function preferAppEnv() { } export async function checkHealth(url: string, password?: string | null): Promise { - let healthUrls: URL[] - try { - healthUrls = [new URL("/api/health", url), new URL("/global/health", url)] - } catch { - return false - } - const headers = new Headers() if (password) { const auth = Buffer.from(`opencode:${password}`).toString("base64") headers.set("authorization", `Basic ${auth}`) } - for (const healthUrl of healthUrls) { - try { - const res = await fetch(healthUrl, { - method: "GET", - headers, - signal: AbortSignal.timeout(3000), - }) - if (res.ok) return true - } catch {} + try { + const res = await fetch(new URL("/api/health", url), { + method: "GET", + headers, + signal: AbortSignal.timeout(3000), + }) + return res.ok + } catch { + return false } - return false } diff --git a/packages/desktop/src/main/store-keys.ts b/packages/desktop/src/main/store-keys.ts index f6f4030dba5..506924b74a6 100644 --- a/packages/desktop/src/main/store-keys.ts +++ b/packages/desktop/src/main/store-keys.ts @@ -1,7 +1,6 @@ export const SETTINGS_STORE = "opencode.settings" export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl" export const FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY = "firstLaunchOnboardingComplete" -export const OLD_LAYOUT_ELIGIBLE_KEY = "oldLayoutEligible" export const WSL_SERVERS_KEY = "wslServers" export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled" export const WINDOW_IDS_KEY = "windowIds" diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 94574f45db3..29c9cd77515 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -11,7 +11,6 @@ const updaterHandler = (_: unknown, state: UpdaterState) => { } const api: ElectronAPI = { - killSidecar: () => ipcRenderer.invoke("kill-sidecar"), awaitInitialization: () => ipcRenderer.invoke("await-initialization"), wslServers: { getState: () => ipcRenderer.invoke("wsl-servers-get-state"), @@ -61,9 +60,6 @@ const api: ElectronAPI = { isFirstLaunchOnboardingPending: () => ipcRenderer.invoke("is-first-launch-onboarding-pending"), finishFirstLaunchOnboarding: (createDefaultProject) => ipcRenderer.invoke("finish-first-launch-onboarding", createDefaultProject), - isOldLayoutEligible: () => ipcRenderer.invoke("is-old-layout-eligible"), - getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"), - setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend), checkAppExists: (appName) => ipcRenderer.invoke("check-app-exists", appName), resolveAppPath: (appName) => ipcRenderer.invoke("resolve-app-path", appName), storeGet: (name, key) => ipcRenderer.invoke("store-get", name, key), diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 50cfa2c1ada..dc3e930ccb1 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -29,7 +29,6 @@ export type UpdaterAPI = { install: () => Promise } -export type LinuxDisplayBackend = "wayland" | "auto" export type TitlebarTheme = { mode: "light" | "dark" scheme?: "system" | "light" | "dark" @@ -43,7 +42,6 @@ export type FatalRendererError = { } export type ElectronAPI = { - killSidecar: () => Promise awaitInitialization: () => Promise wslServers: WslServersAPI updater: UpdaterAPI @@ -52,9 +50,6 @@ export type ElectronAPI = { setDefaultServerUrl: (url: string | null) => Promise isFirstLaunchOnboardingPending: () => Promise finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise - isOldLayoutEligible: () => Promise - getDisplayBackend: () => Promise - setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise checkAppExists: (appName: string) => Promise resolveAppPath: (appName: string) => Promise storeGet: (name: string, key: string) => Promise diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index a99e8305ac8..85a3da00662 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -69,7 +69,7 @@ void window.api.updater.subscribe(setUpdaterState) const deepLinkEvent = "opencode:deep-link" type DesktopWindowState = { - id?: string + id: string } const emitDeepLinks = (urls: string[]) => { @@ -93,7 +93,10 @@ function getLastActiveUrl(windowID: string) { if (typeof localStorage !== "object") return "/" try { const value = localStorage.getItem(windowLastActiveUrlKey(windowID)) - if (value?.startsWith("/") && !value.startsWith("//")) return value + if (value === "/") return value + const path = value?.split(/[?#]/, 1)[0] + if (path === "/new-session") return value ?? "/" + if (/^\/server\/[^/]+\/session\/[^/]+$/.test(path ?? "")) return value ?? "/" } catch {} return "/" } @@ -249,10 +252,7 @@ const createPlatform = (windowState: DesktopWindowState): Platform => { recordFatalRendererError: (error) => window.api.recordFatalRendererError(error), - restart: async () => { - await window.api.killSidecar().catch(() => undefined) - window.api.relaunch() - }, + restart: async () => window.api.relaunch(), notify: async (title, description, onClick) => { const focused = await window.api.getWindowFocused().catch(() => document.hasFocus()) @@ -287,14 +287,6 @@ const createPlatform = (windowState: DesktopWindowState): Platform => { wslServers: wslServersApi, - getDisplayBackend: async () => { - return window.api.getDisplayBackend().catch(() => null) - }, - - setDisplayBackend: async (backend) => { - await window.api.setDisplayBackend(backend) - }, - webviewZoom, windowFullscreen, @@ -337,9 +329,7 @@ function LoadingSplash() { function DesktopRoot(props: { windowState: DesktopWindowState }) { const platform = createPlatform(props.windowState) const loadLocale = async () => { - const current = await platform.storage?.("opencode.global.dat").getItem("language") - const legacy = current ? undefined : await platform.storage?.().getItem("language.v1") - const raw = current ?? legacy + const raw = await platform.storage?.("opencode.global.dat").getItem("language") if (!raw) return const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1] if (!locale) return @@ -353,11 +343,9 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) { const [defaultServer] = createResource(() => platform.getDefaultServer?.()) const [locale] = createResource(loadLocale) - const router = (props: BaseRouterProps) => ( - + const router = (routerProps: BaseRouterProps) => ( + ) - const onboarding = Promise.withResolvers() - function DesktopEffects() { const cmd = useCommand() menuTrigger = (id) => cmd.trigger(id) @@ -407,12 +395,8 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) { }> {(key) => ( - - + + {(server) => } @@ -437,12 +421,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) { } render(() => { - const [windowState] = createResource(async () => { - const api = window.api as typeof window.api & { - getWindowID?: () => Promise - } - return { id: await api.getWindowID?.() } - }) + const [windowState] = createResource(() => window.api.getWindowID().then((id) => ({ id }))) return ( } keyed> diff --git a/packages/desktop/src/renderer/onboarding.tsx b/packages/desktop/src/renderer/onboarding.tsx index 2e56d8a15a3..0638c19d21a 100644 --- a/packages/desktop/src/renderer/onboarding.tsx +++ b/packages/desktop/src/renderer/onboarding.tsx @@ -1,36 +1,26 @@ import { ServerConnection, useServers, useTabs } from "@opencode-ai/app" import { onMount } from "solid-js" -export function DesktopFirstLaunchOnboarding(props: { - serverKey: ServerConnection.Key - initialUrl: string - onLoaded: () => void -}) { +export function DesktopFirstLaunchOnboarding(props: { serverKey: ServerConnection.Key; initialUrl: string }) { const server = useServers() const tabs = useTabs() onMount(() => { - void runFirstLaunchOnboarding().finally(props.onLoaded) + void runFirstLaunchOnboarding() }) async function runFirstLaunchOnboarding() { try { await Promise.all([tabs.ready.promise, tabs.recentReady.promise].map((p) => p ?? Promise.resolve())) - const existingInstall = await window.api.isOldLayoutEligible() - const pending = await window.api.isFirstLaunchOnboardingPending() if (!pending) return const shouldTrigger = - !existingInstall && - props.initialUrl === "/" && - tabs.store.length === 0 && - server.list.every(ServerConnection.builtin) + props.initialUrl === "/" && tabs.store.length === 0 && server.list.every(ServerConnection.builtin) console.info("[desktop-onboarding] first launch onboarding evaluated", { pending, shouldTrigger, - existingInstall, initialUrl: props.initialUrl, tabs: tabs.store.length, servers: server.list.map(ServerConnection.key), diff --git a/packages/ui/src/theme/context.tsx b/packages/ui/src/theme/context.tsx index f225ee33ac0..4970ed910c7 100644 --- a/packages/ui/src/theme/context.tsx +++ b/packages/ui/src/theme/context.tsx @@ -84,8 +84,9 @@ const names: Record = { } const oc2Theme = oc2ThemeJson as DesktopTheme -function normalize(id: string | null | undefined) { - return id === "oc-1" ? "oc-2" : id +function resolveStoredTheme(id: string | null | undefined, registered?: Record) { + if (id === "oc-2" || (id && (knownThemes().has(id) || registered?.[id]))) return id + return "oc-2" } function read(key: string) { @@ -177,7 +178,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ defaultTheme?: string onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark", scheme: ColorScheme) => void }) => { - const themeId = normalize(read(STORAGE_KEYS.THEME_ID) ?? props.defaultTheme) ?? "oc-2" + const rawTheme = read(STORAGE_KEYS.THEME_ID) ?? props.defaultTheme + const themeId = resolveStoredTheme(rawTheme) + if (rawTheme && rawTheme !== themeId) { + write(STORAGE_KEYS.THEME_ID, themeId) + clear() + } const colorScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system" const mode = colorScheme === "system" ? getSystemMode() : colorScheme const [store, setStore] = createStore({ @@ -194,7 +200,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ const loads = new Map>() const load = (id: string) => { - const next = normalize(id) + const next = id if (!next) return Promise.resolve(undefined) const hit = store.themes[next] if (hit) return Promise.resolve(hit) @@ -233,9 +239,11 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ const onStorage = (e: StorageEvent) => { if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) { - const next = normalize(e.newValue) - if (!next) return - if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return + const next = resolveStoredTheme(e.newValue, store.themes) + if (next !== e.newValue) { + write(STORAGE_KEYS.THEME_ID, next) + clear() + } setStore("themeId", next) if (next === "oc-2") { clear() @@ -262,8 +270,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ } makeEventListener(mediaQuery, "change", onMedia) - const rawTheme = read(STORAGE_KEYS.THEME_ID) - const savedTheme = normalize(rawTheme ?? props.defaultTheme) ?? "oc-2" + const rawTheme = read(STORAGE_KEYS.THEME_ID) ?? props.defaultTheme + const savedTheme = resolveStoredTheme(rawTheme, store.themes) const savedScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system" if (rawTheme && rawTheme !== savedTheme) { write(STORAGE_KEYS.THEME_ID, savedTheme) @@ -285,7 +293,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ }) const setTheme = (id: string) => { - const next = normalize(id) + const next = id if (!next) { console.warn(`Theme "${id}" not found`) return @@ -325,7 +333,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ setColorScheme, registerTheme: (theme: DesktopTheme) => setStore("themes", theme.id, theme), previewTheme: (id: string) => { - const next = normalize(id) + const next = id if (!next) return if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return setStore("previewThemeId", next)