From 03d7467ea268f2f0f8d99f48ea1522741014b4bf Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sat, 17 Jan 2026 20:52:21 -0600 Subject: [PATCH 01/42] test(app): initial e2e test setup --- .github/workflows/test.yml | 63 +++++++++++++++++++++++ bun.lock | 10 ++++ package.json | 1 + packages/app/.gitignore | 2 + packages/app/README.md | 15 ++++++ packages/app/e2e/home.spec.ts | 6 +++ packages/app/package.json | 7 ++- packages/app/playwright.config.ts | 43 ++++++++++++++++ packages/opencode/script/seed-e2e.ts | 50 ++++++++++++++++++ packages/opencode/src/share/share-next.ts | 6 +++ packages/opencode/src/share/share.ts | 5 ++ 11 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 packages/app/e2e/home.spec.ts create mode 100644 packages/app/playwright.config.ts create mode 100644 packages/opencode/script/seed-e2e.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c39710bee8..98eac1dab2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,6 +18,52 @@ jobs: - name: Setup Bun uses: ./.github/actions/setup-bun + - name: Install Playwright browsers + run: bun --cwd packages/app x playwright install --with-deps + + - name: Seed opencode data + run: bun --cwd packages/opencode script/seed-e2e.ts + env: + MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json + OPENCODE_DISABLE_MODELS_FETCH: "true" + OPENCODE_DISABLE_SHARE: "true" + OPENCODE_DISABLE_LSP_DOWNLOAD: "true" + OPENCODE_DISABLE_DEFAULT_PLUGINS: "true" + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" + OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home + XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share + XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache + XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config + XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state + OPENCODE_E2E_PROJECT_DIR: ${{ github.workspace }} + OPENCODE_E2E_SESSION_TITLE: "E2E Session" + OPENCODE_E2E_MESSAGE: "Seeded for UI e2e" + OPENCODE_E2E_MODEL: "opencode/gpt-5-nano" + + - name: Run opencode server + run: bun --cwd packages/opencode run dev -- --print-logs --log-level WARN serve --port 4096 --hostname 0.0.0.0 & + env: + MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json + OPENCODE_DISABLE_MODELS_FETCH: "true" + OPENCODE_DISABLE_SHARE: "true" + OPENCODE_DISABLE_LSP_DOWNLOAD: "true" + OPENCODE_DISABLE_DEFAULT_PLUGINS: "true" + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" + OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home + XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share + XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache + XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config + XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state + OPENCODE_CLIENT: "app" + + - name: Wait for opencode server + run: | + for i in {1..60}; do + curl -fsS "http://localhost:4096/global/health" > /dev/null && exit 0 + sleep 1 + done + exit 1 + - name: run run: | git config --global user.email "bot@opencode.ai" @@ -26,3 +72,20 @@ jobs: bun turbo test env: CI: true + MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json + OPENCODE_DISABLE_MODELS_FETCH: "true" + OPENCODE_DISABLE_SHARE: "true" + OPENCODE_DISABLE_LSP_DOWNLOAD: "true" + OPENCODE_DISABLE_DEFAULT_PLUGINS: "true" + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" + OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home + XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share + XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache + XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config + XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state + PLAYWRIGHT_SERVER_HOST: "localhost" + PLAYWRIGHT_SERVER_PORT: "4096" + VITE_OPENCODE_SERVER_HOST: "localhost" + VITE_OPENCODE_SERVER_PORT: "4096" + OPENCODE_CLIENT: "app" + timeout-minutes: 30 diff --git a/bun.lock b/bun.lock index a9cabb3111..e5892a7745 100644 --- a/bun.lock +++ b/bun.lock @@ -56,6 +56,7 @@ }, "devDependencies": { "@happy-dom/global-registrator": "20.0.11", + "@playwright/test": "1.57.0", "@tailwindcss/vite": "catalog:", "@tsconfig/bun": "1.0.9", "@types/bun": "catalog:", @@ -502,6 +503,7 @@ "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", "@pierre/diffs": "1.0.2", + "@playwright/test": "1.51.0", "@solid-primitives/storage": "4.3.3", "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", @@ -1355,6 +1357,8 @@ "@planetscale/database": ["@planetscale/database@1.19.0", "", {}, "sha512-Tv4jcFUFAFjOWrGSio49H6R2ijALv0ZzVBfJKIdm+kl9X046Fh4LLawrF9OMsglVbK6ukqMJsUCeucGAFTBcMA=="], + "@playwright/test": ["@playwright/test@1.57.0", "", { "dependencies": { "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" } }, "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA=="], + "@poppinss/colors": ["@poppinss/colors@4.1.5", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw=="], "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], @@ -3291,6 +3295,10 @@ "planck": ["planck@1.4.2", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-mNbhnV3g8X2rwGxzcesjmN8BDA6qfXgQxXVMkWau9MCRlQY0RLNEkyHlVp6yFy/X6qrzAXyNONCnZ1cGDLrNew=="], + "playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], + + "playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], @@ -4427,6 +4435,8 @@ "pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], diff --git a/package.json b/package.json index f1d6c4fead..ca9602174a 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "luxon": "3.6.1", "marked": "17.0.1", "marked-shiki": "1.2.1", + "@playwright/test": "1.51.0", "typescript": "5.8.2", "@typescript/native-preview": "7.0.0-dev.20251207.1", "zod": "4.1.8", diff --git a/packages/app/.gitignore b/packages/app/.gitignore index 4a20d55a70..d699efb38d 100644 --- a/packages/app/.gitignore +++ b/packages/app/.gitignore @@ -1 +1,3 @@ src/assets/theme.css +e2e/test-results +e2e/playwright-report diff --git a/packages/app/README.md b/packages/app/README.md index bd10e6c8dd..42a6881509 100644 --- a/packages/app/README.md +++ b/packages/app/README.md @@ -29,6 +29,21 @@ It correctly bundles Solid in production mode and optimizes the build for the be The build is minified and the filenames include the hashes.
Your app is ready to be deployed! +## E2E Testing + +The Playwright runner expects the app already running at `http://localhost:3000`. + +```bash +bun add -D @playwright/test +bunx playwright install +bun run test:e2e +``` + +Environment options: + +- `PLAYWRIGHT_BASE_URL` (default: `http://localhost:3000`) +- `PLAYWRIGHT_PORT` (default: `3000`) + ## Deployment You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.) diff --git a/packages/app/e2e/home.spec.ts b/packages/app/e2e/home.spec.ts new file mode 100644 index 0000000000..ff57923d58 --- /dev/null +++ b/packages/app/e2e/home.spec.ts @@ -0,0 +1,6 @@ +import { test, expect } from "@playwright/test" + +test("home shows recent projects header", async ({ page }) => { + await page.goto("/") + await expect(page.getByText("Recent projects")).toBeVisible() +}) diff --git a/packages/app/package.json b/packages/app/package.json index 38d9a25f50..2a754c9673 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -12,11 +12,16 @@ "start": "vite", "dev": "vite", "build": "vite build", - "serve": "vite preview" + "serve": "vite preview", + "test": "playwright test", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:report": "playwright show-report e2e/playwright-report" }, "license": "MIT", "devDependencies": { "@happy-dom/global-registrator": "20.0.11", + "@playwright/test": "1.57.0", "@tailwindcss/vite": "catalog:", "@tsconfig/bun": "1.0.9", "@types/bun": "catalog:", diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts new file mode 100644 index 0000000000..10819e69ff --- /dev/null +++ b/packages/app/playwright.config.ts @@ -0,0 +1,43 @@ +import { defineConfig, devices } from "@playwright/test" + +const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000) +const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${port}` +const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" +const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" +const command = `bun run dev -- --host 0.0.0.0 --port ${port}` +const reuse = !process.env.CI + +export default defineConfig({ + testDir: "./e2e", + outputDir: "./e2e/test-results", + timeout: 60_000, + expect: { + timeout: 10_000, + }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]], + webServer: { + command, + url: baseURL, + reuseExistingServer: reuse, + timeout: 120_000, + env: { + VITE_OPENCODE_SERVER_HOST: serverHost, + VITE_OPENCODE_SERVER_PORT: serverPort, + }, + }, + use: { + baseURL, + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}) diff --git a/packages/opencode/script/seed-e2e.ts b/packages/opencode/script/seed-e2e.ts new file mode 100644 index 0000000000..ba2155cb69 --- /dev/null +++ b/packages/opencode/script/seed-e2e.ts @@ -0,0 +1,50 @@ +const dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd() +const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session" +const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e" +const model = process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano" +const parts = model.split("/") +const providerID = parts[0] ?? "opencode" +const modelID = parts[1] ?? "gpt-5-nano" +const now = Date.now() + +const seed = async () => { + const { Instance } = await import("../src/project/instance") + const { InstanceBootstrap } = await import("../src/project/bootstrap") + const { Session } = await import("../src/session") + const { Identifier } = await import("../src/id/id") + const { Project } = await import("../src/project/project") + + await Instance.provide({ + directory: dir, + init: InstanceBootstrap, + fn: async () => { + const session = await Session.create({ title }) + const messageID = Identifier.descending("message") + const partID = Identifier.descending("part") + const message = { + id: messageID, + sessionID: session.id, + role: "user" as const, + time: { created: now }, + agent: "build", + model: { + providerID, + modelID, + }, + } + const part = { + id: partID, + sessionID: session.id, + messageID, + type: "text" as const, + text, + time: { start: now }, + } + await Session.updateMessage(message) + await Session.updatePart(part) + await Project.update({ projectID: Instance.project.id, name: "E2E Project" }) + }, + }) +} + +await seed() diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 95271f8c82..dddce95cb4 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -15,7 +15,10 @@ export namespace ShareNext { return Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai") } + const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1" + export async function init() { + if (disabled) return Bus.subscribe(Session.Event.Updated, async (evt) => { await sync(evt.properties.info.id, [ { @@ -63,6 +66,7 @@ export namespace ShareNext { } export async function create(sessionID: string) { + if (disabled) return { id: "", url: "", secret: "" } log.info("creating share", { sessionID }) const result = await fetch(`${await url()}/api/share`, { method: "POST", @@ -110,6 +114,7 @@ export namespace ShareNext { const queue = new Map }>() async function sync(sessionID: string, data: Data[]) { + if (disabled) return const existing = queue.get(sessionID) if (existing) { for (const item of data) { @@ -145,6 +150,7 @@ export namespace ShareNext { } export async function remove(sessionID: string) { + if (disabled) return log.info("removing share", { sessionID }) const share = await get(sessionID) if (!share) return diff --git a/packages/opencode/src/share/share.ts b/packages/opencode/src/share/share.ts index 1006b23d55..f7bf4b3fa5 100644 --- a/packages/opencode/src/share/share.ts +++ b/packages/opencode/src/share/share.ts @@ -11,6 +11,7 @@ export namespace Share { const pending = new Map() export async function sync(key: string, content: any) { + if (disabled) return const [root, ...splits] = key.split("/") if (root !== "session") return const [sub, sessionID] = splits @@ -69,7 +70,10 @@ export namespace Share { process.env["OPENCODE_API"] ?? (Installation.isPreview() || Installation.isLocal() ? "https://api.dev.opencode.ai" : "https://api.opencode.ai") + const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1" + export async function create(sessionID: string) { + if (disabled) return { url: "", secret: "" } return fetch(`${URL}/share_create`, { method: "POST", body: JSON.stringify({ sessionID: sessionID }), @@ -79,6 +83,7 @@ export namespace Share { } export async function remove(sessionID: string, secret: string) { + if (disabled) return {} return fetch(`${URL}/share_delete`, { method: "POST", body: JSON.stringify({ sessionID, secret }), From 19d15ca4dfb4eaad34e3b9e0a3b8f9e206d094c3 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 18 Jan 2026 05:15:34 -0600 Subject: [PATCH 02/42] test(app): more e2e tests --- packages/app/e2e/home.spec.ts | 22 ++++++++++++++++-- packages/app/e2e/session.spec.ts | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 packages/app/e2e/session.spec.ts diff --git a/packages/app/e2e/home.spec.ts b/packages/app/e2e/home.spec.ts index ff57923d58..29a0706093 100644 --- a/packages/app/e2e/home.spec.ts +++ b/packages/app/e2e/home.spec.ts @@ -1,6 +1,24 @@ import { test, expect } from "@playwright/test" -test("home shows recent projects header", async ({ page }) => { +test("home renders and shows an open project entrypoint", async ({ page }) => { await page.goto("/") - await expect(page.getByText("Recent projects")).toBeVisible() + + await expect(page.getByText("Recent projects").or(page.getByText("No recent projects"))).toBeVisible() + await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible() +}) + +test("server picker dialog opens from home", async ({ page }) => { + const host = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" + const port = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" + const name = `${host}:${port}` + + await page.goto("/") + + const trigger = page.getByRole("button", { name }) + await expect(trigger).toBeVisible() + await trigger.click() + + const dialog = page.getByRole("dialog", { name: "Servers" }) + await expect(dialog).toBeVisible() + await expect(dialog.getByPlaceholder("Search servers")).toBeVisible() }) diff --git a/packages/app/e2e/session.spec.ts b/packages/app/e2e/session.spec.ts new file mode 100644 index 0000000000..e1ca12449b --- /dev/null +++ b/packages/app/e2e/session.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from "@playwright/test" +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { base64Encode } from "@opencode-ai/util/encode" + +const host = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" +const port = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" +const url = `http://${host}:${port}` + +async function getWorktree() { + const sdk = createOpencodeClient({ baseUrl: url, throwOnError: true }) + const result = await sdk.path.get() + const data = result.data + if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${url}/path`) + return data.worktree +} + +test("can open an existing session and type into the prompt", async ({ page }) => { + const directory = await getWorktree() + const sdk = createOpencodeClient({ baseUrl: url, directory, throwOnError: true }) + const title = `e2e smoke ${Date.now()}` + const created = await sdk.session.create({ title }).then((r) => r.data) + + if (!created?.id) throw new Error("Session create did not return an id") + const sessionID = created.id + + try { + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + + const prompt = page.locator('[data-component="prompt-input"]') + await expect(prompt).toBeVisible() + + await prompt.click() + await page.keyboard.type("hello from e2e") + await expect(prompt).toContainText("hello from e2e") + } finally { + await sdk.session.delete({ sessionID }).catch(() => undefined) + } +}) From 91a708b12e3dc0219517aa93dbd30ef2a4aea6c8 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 18 Jan 2026 05:36:02 -0600 Subject: [PATCH 03/42] test(app): more e2e tests --- packages/app/e2e/navigation.spec.ts | 24 ++++++++++++++++++++ packages/app/e2e/palette.spec.ts | 34 +++++++++++++++++++++++++++++ packages/app/e2e/terminal.spec.ts | 33 ++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 packages/app/e2e/navigation.spec.ts create mode 100644 packages/app/e2e/palette.spec.ts create mode 100644 packages/app/e2e/terminal.spec.ts diff --git a/packages/app/e2e/navigation.spec.ts b/packages/app/e2e/navigation.spec.ts new file mode 100644 index 0000000000..2783c5222c --- /dev/null +++ b/packages/app/e2e/navigation.spec.ts @@ -0,0 +1,24 @@ +import { test, expect } from "@playwright/test" +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { base64Encode } from "@opencode-ai/util/encode" + +const host = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" +const port = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" +const url = `http://${host}:${port}` + +async function getWorktree() { + const sdk = createOpencodeClient({ baseUrl: url, throwOnError: true }) + const result = await sdk.path.get() + const data = result.data + if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${url}/path`) + return data.worktree +} + +test("project route redirects to /session", async ({ page }) => { + const directory = await getWorktree() + const slug = base64Encode(directory) + + await page.goto(`/${slug}`) + await expect(page).toHaveURL(new RegExp(`/${slug}/session`)) + await expect(page.locator('[data-component="prompt-input"]')).toBeVisible() +}) diff --git a/packages/app/e2e/palette.spec.ts b/packages/app/e2e/palette.spec.ts new file mode 100644 index 0000000000..0629456294 --- /dev/null +++ b/packages/app/e2e/palette.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from "@playwright/test" +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { base64Encode } from "@opencode-ai/util/encode" + +const host = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" +const port = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" +const url = `http://${host}:${port}` + +async function getWorktree() { + const sdk = createOpencodeClient({ baseUrl: url, throwOnError: true }) + const result = await sdk.path.get() + const data = result.data + if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${url}/path`) + return data.worktree +} + +const mod = process.platform === "darwin" ? "Meta" : "Control" + +test("search palette opens and closes", async ({ page }) => { + const directory = await getWorktree() + const slug = base64Encode(directory) + + await page.goto(`/${slug}/session`) + await expect(page.locator('[data-component="prompt-input"]')).toBeVisible() + + await page.keyboard.press(`${mod}+P`) + + const dialog = page.getByRole("dialog") + await expect(dialog).toBeVisible() + await expect(dialog.getByRole("textbox").first()).toBeVisible() + + await page.keyboard.press("Escape") + await expect(dialog).toHaveCount(0) +}) diff --git a/packages/app/e2e/terminal.spec.ts b/packages/app/e2e/terminal.spec.ts new file mode 100644 index 0000000000..9bb9947fe3 --- /dev/null +++ b/packages/app/e2e/terminal.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from "@playwright/test" +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { base64Encode } from "@opencode-ai/util/encode" + +const host = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" +const port = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" +const url = `http://${host}:${port}` + +async function getWorktree() { + const sdk = createOpencodeClient({ baseUrl: url, throwOnError: true }) + const result = await sdk.path.get() + const data = result.data + if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${url}/path`) + return data.worktree +} + +test("terminal panel can be toggled", async ({ page }) => { + const directory = await getWorktree() + const slug = base64Encode(directory) + + await page.goto(`/${slug}/session`) + await expect(page.locator('[data-component="prompt-input"]')).toBeVisible() + + const terminal = page.locator('[data-component="terminal"]') + const initiallyOpen = await terminal.isVisible() + if (initiallyOpen) { + await page.keyboard.press("Control+Backquote") + await expect(terminal).toHaveCount(0) + } + + await page.keyboard.press("Control+Backquote") + await expect(terminal).toBeVisible() +}) From 7621c5cafbc5f853806bf8a9c95ebaa727bda15f Mon Sep 17 00:00:00 2001 From: Github Action Date: Sun, 18 Jan 2026 11:36:58 +0000 Subject: [PATCH 04/42] Update flake.lock --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 5ef276f0a0..87f95fb3eb 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1768302833, - "narHash": "sha256-h5bRFy9bco+8QcK7rGoOiqMxMbmn21moTACofNLRMP4=", + "lastModified": 1768569498, + "narHash": "sha256-bB6Nt99Cj8Nu5nIUq0GLmpiErIT5KFshMQJGMZwgqUo=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "61db79b0c6b838d9894923920b612048e1201926", + "rev": "be5afa0fcb31f0a96bf9ecba05a516c66fcd8114", "type": "github" }, "original": { From 6bc823bd40935117b0f6cd4e0e6d00fd9ea64bfe Mon Sep 17 00:00:00 2001 From: Github Action Date: Sun, 18 Jan 2026 11:37:49 +0000 Subject: [PATCH 05/42] Update node_modules hash (x86_64-linux) --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 5bbdf921bb..a7a7957e24 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-D1VXuKJagfq3mxh8Xs8naHoYNJUJzAM9JLJqpHcItDk=", - "aarch64-linux": "sha256-9wXcg50Sv56Wb2x5NWe15olNGE/uMiDkmGRmqPoeW1U=", - "aarch64-darwin": "sha256-i5eTTjsNAARwcw69sd6wuse2BKTUi/Vfgo4M28l+RoY=", - "x86_64-darwin": "sha256-oFtQnIzgTS2zcjkhBTnXxYqr20KXdA2I+b908piLs+c=" + "x86_64-linux": "sha256-DFsg3Dyt+cm8Z7CU6lpFd4gTAzydAewfiQW1T3qvDxA=", + "aarch64-linux": "sha256-0Im52dLeZ0ZtaPJr/U4m7+IRtOfziHNJI/Bu/V6cPho=", + "aarch64-darwin": "sha256-U2UvE70nM0OI0VhIku8qnX+ptPbA+Q/y1BGXbFMcyt4=", + "x86_64-darwin": "sha256-CpZFHBMPJSib2Vqs6oC8HQjQtviPUMa/qezHAe22N/A=" } } From f5eb90514aca136e0aa2f48954f71d51b52e2b16 Mon Sep 17 00:00:00 2001 From: Github Action Date: Sun, 18 Jan 2026 11:43:58 +0000 Subject: [PATCH 06/42] Update node_modules hash (aarch64-darwin) --- nix/hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/hashes.json b/nix/hashes.json index a7a7957e24..e51ed90463 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -2,7 +2,7 @@ "nodeModules": { "x86_64-linux": "sha256-DFsg3Dyt+cm8Z7CU6lpFd4gTAzydAewfiQW1T3qvDxA=", "aarch64-linux": "sha256-0Im52dLeZ0ZtaPJr/U4m7+IRtOfziHNJI/Bu/V6cPho=", - "aarch64-darwin": "sha256-U2UvE70nM0OI0VhIku8qnX+ptPbA+Q/y1BGXbFMcyt4=", + "aarch64-darwin": "sha256-Rfd05h/BRLoM0SmKn+ui2RdXBNhkaVKkgBeT+uBs4J8=", "x86_64-darwin": "sha256-CpZFHBMPJSib2Vqs6oC8HQjQtviPUMa/qezHAe22N/A=" } } From dd19c3d8f2cfbb7d1b157bbe6f5a9a969cb6c239 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 18 Jan 2026 05:43:34 -0600 Subject: [PATCH 07/42] test(app): e2e utilities --- packages/app/e2e/home.spec.ts | 15 ++++------ packages/app/e2e/navigation.spec.ts | 21 +++----------- packages/app/e2e/palette.spec.ts | 26 +++-------------- packages/app/e2e/session.spec.ts | 21 +++----------- packages/app/e2e/terminal.spec.ts | 28 ++++-------------- packages/app/e2e/utils.ts | 45 +++++++++++++++++++++++++++++ 6 files changed, 69 insertions(+), 87 deletions(-) create mode 100644 packages/app/e2e/utils.ts diff --git a/packages/app/e2e/home.spec.ts b/packages/app/e2e/home.spec.ts index 29a0706093..5bb701076e 100644 --- a/packages/app/e2e/home.spec.ts +++ b/packages/app/e2e/home.spec.ts @@ -1,24 +1,21 @@ import { test, expect } from "@playwright/test" +import { serverName } from "./utils" -test("home renders and shows an open project entrypoint", async ({ page }) => { +test("home renders and shows core entrypoints", async ({ page }) => { await page.goto("/") - await expect(page.getByText("Recent projects").or(page.getByText("No recent projects"))).toBeVisible() await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible() + await expect(page.getByRole("button", { name: serverName })).toBeVisible() }) test("server picker dialog opens from home", async ({ page }) => { - const host = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" - const port = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" - const name = `${host}:${port}` - await page.goto("/") - const trigger = page.getByRole("button", { name }) + const trigger = page.getByRole("button", { name: serverName }) await expect(trigger).toBeVisible() await trigger.click() - const dialog = page.getByRole("dialog", { name: "Servers" }) + const dialog = page.getByRole("dialog") await expect(dialog).toBeVisible() - await expect(dialog.getByPlaceholder("Search servers")).toBeVisible() + await expect(dialog.getByRole("textbox").first()).toBeVisible() }) diff --git a/packages/app/e2e/navigation.spec.ts b/packages/app/e2e/navigation.spec.ts index 2783c5222c..4d0d3b2b9d 100644 --- a/packages/app/e2e/navigation.spec.ts +++ b/packages/app/e2e/navigation.spec.ts @@ -1,24 +1,11 @@ import { test, expect } from "@playwright/test" -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" -import { base64Encode } from "@opencode-ai/util/encode" - -const host = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" -const port = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" -const url = `http://${host}:${port}` - -async function getWorktree() { - const sdk = createOpencodeClient({ baseUrl: url, throwOnError: true }) - const result = await sdk.path.get() - const data = result.data - if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${url}/path`) - return data.worktree -} +import { dirPath, dirSlug, getWorktree, promptSelector } from "./utils" test("project route redirects to /session", async ({ page }) => { const directory = await getWorktree() - const slug = base64Encode(directory) + const slug = dirSlug(directory) - await page.goto(`/${slug}`) + await page.goto(dirPath(directory)) await expect(page).toHaveURL(new RegExp(`/${slug}/session`)) - await expect(page.locator('[data-component="prompt-input"]')).toBeVisible() + await expect(page.locator(promptSelector)).toBeVisible() }) diff --git a/packages/app/e2e/palette.spec.ts b/packages/app/e2e/palette.spec.ts index 0629456294..bad09aab96 100644 --- a/packages/app/e2e/palette.spec.ts +++ b/packages/app/e2e/palette.spec.ts @@ -1,29 +1,11 @@ import { test, expect } from "@playwright/test" -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" -import { base64Encode } from "@opencode-ai/util/encode" - -const host = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" -const port = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" -const url = `http://${host}:${port}` - -async function getWorktree() { - const sdk = createOpencodeClient({ baseUrl: url, throwOnError: true }) - const result = await sdk.path.get() - const data = result.data - if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${url}/path`) - return data.worktree -} - -const mod = process.platform === "darwin" ? "Meta" : "Control" +import { gotoSession, modKey, promptSelector } from "./utils" test("search palette opens and closes", async ({ page }) => { - const directory = await getWorktree() - const slug = base64Encode(directory) + await gotoSession(page) + await expect(page.locator(promptSelector)).toBeVisible() - await page.goto(`/${slug}/session`) - await expect(page.locator('[data-component="prompt-input"]')).toBeVisible() - - await page.keyboard.press(`${mod}+P`) + await page.keyboard.press(`${modKey}+P`) const dialog = page.getByRole("dialog") await expect(dialog).toBeVisible() diff --git a/packages/app/e2e/session.spec.ts b/packages/app/e2e/session.spec.ts index e1ca12449b..d44736a4f5 100644 --- a/packages/app/e2e/session.spec.ts +++ b/packages/app/e2e/session.spec.ts @@ -1,22 +1,9 @@ import { test, expect } from "@playwright/test" -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" -import { base64Encode } from "@opencode-ai/util/encode" - -const host = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" -const port = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" -const url = `http://${host}:${port}` - -async function getWorktree() { - const sdk = createOpencodeClient({ baseUrl: url, throwOnError: true }) - const result = await sdk.path.get() - const data = result.data - if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${url}/path`) - return data.worktree -} +import { createSdk, getWorktree, promptSelector, sessionPath } from "./utils" test("can open an existing session and type into the prompt", async ({ page }) => { const directory = await getWorktree() - const sdk = createOpencodeClient({ baseUrl: url, directory, throwOnError: true }) + const sdk = createSdk(directory) const title = `e2e smoke ${Date.now()}` const created = await sdk.session.create({ title }).then((r) => r.data) @@ -24,9 +11,9 @@ test("can open an existing session and type into the prompt", async ({ page }) = const sessionID = created.id try { - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await page.goto(sessionPath(directory, sessionID)) - const prompt = page.locator('[data-component="prompt-input"]') + const prompt = page.locator(promptSelector) await expect(prompt).toBeVisible() await prompt.click() diff --git a/packages/app/e2e/terminal.spec.ts b/packages/app/e2e/terminal.spec.ts index 9bb9947fe3..8f90a3c0c1 100644 --- a/packages/app/e2e/terminal.spec.ts +++ b/packages/app/e2e/terminal.spec.ts @@ -1,33 +1,17 @@ import { test, expect } from "@playwright/test" -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" -import { base64Encode } from "@opencode-ai/util/encode" - -const host = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" -const port = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" -const url = `http://${host}:${port}` - -async function getWorktree() { - const sdk = createOpencodeClient({ baseUrl: url, throwOnError: true }) - const result = await sdk.path.get() - const data = result.data - if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${url}/path`) - return data.worktree -} +import { gotoSession, promptSelector, terminalSelector, terminalToggleKey } from "./utils" test("terminal panel can be toggled", async ({ page }) => { - const directory = await getWorktree() - const slug = base64Encode(directory) + await gotoSession(page) + await expect(page.locator(promptSelector)).toBeVisible() - await page.goto(`/${slug}/session`) - await expect(page.locator('[data-component="prompt-input"]')).toBeVisible() - - const terminal = page.locator('[data-component="terminal"]') + const terminal = page.locator(terminalSelector) const initiallyOpen = await terminal.isVisible() if (initiallyOpen) { - await page.keyboard.press("Control+Backquote") + await page.keyboard.press(terminalToggleKey) await expect(terminal).toHaveCount(0) } - await page.keyboard.press("Control+Backquote") + await page.keyboard.press(terminalToggleKey) await expect(terminal).toBeVisible() }) diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts new file mode 100644 index 0000000000..c18b368029 --- /dev/null +++ b/packages/app/e2e/utils.ts @@ -0,0 +1,45 @@ +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { base64Encode } from "@opencode-ai/util/encode" +import type { Page } from "@playwright/test" + +export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" +export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" + +export const serverUrl = `http://${serverHost}:${serverPort}` +export const serverName = `${serverHost}:${serverPort}` + +export const modKey = process.platform === "darwin" ? "Meta" : "Control" +export const terminalToggleKey = "Control+Backquote" + +export const promptSelector = '[data-component="prompt-input"]' +export const terminalSelector = '[data-component="terminal"]' + +export function createSdk(directory?: string) { + return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true }) +} + +export async function getWorktree() { + const sdk = createSdk() + const result = await sdk.path.get() + const data = result.data + if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${serverUrl}/path`) + return data.worktree +} + +export function dirSlug(directory: string) { + return base64Encode(directory) +} + +export function dirPath(directory: string) { + return `/${dirSlug(directory)}` +} + +export function sessionPath(directory: string, sessionID?: string) { + return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}` +} + +export async function gotoSession(page: Page, sessionID?: string) { + const directory = await getWorktree() + await page.goto(sessionPath(directory, sessionID)) + return { directory, slug: dirSlug(directory) } +} From f1daf3b4308cf85a3f61fb2f1d4b3f55316487b1 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 18 Jan 2026 05:45:24 -0600 Subject: [PATCH 08/42] fix(app): tests in ci --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 98eac1dab2..fa93b61cf6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: uses: ./.github/actions/setup-bun - name: Install Playwright browsers - run: bun --cwd packages/app x playwright install --with-deps + run: bunx --cwd packages/app playwright install --with-deps - name: Seed opencode data run: bun --cwd packages/opencode script/seed-e2e.ts From 182c43a78f55d4c22440c7256cbf4ff464e01c6e Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 19 Jan 2026 05:22:35 -0600 Subject: [PATCH 09/42] chore: cleanup --- packages/app/e2e/fixtures.ts | 40 +++++++++++++++++++++++++++++ packages/app/e2e/home.spec.ts | 2 +- packages/app/e2e/navigation.spec.ts | 10 +++----- packages/app/e2e/palette.spec.ts | 9 +++---- packages/app/e2e/session.spec.ts | 12 +++------ packages/app/e2e/terminal.spec.ts | 9 +++---- packages/app/e2e/utils.ts | 7 ----- 7 files changed, 57 insertions(+), 32 deletions(-) create mode 100644 packages/app/e2e/fixtures.ts diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts new file mode 100644 index 0000000000..721d60049c --- /dev/null +++ b/packages/app/e2e/fixtures.ts @@ -0,0 +1,40 @@ +import { test as base, expect } from "@playwright/test" +import { createSdk, dirSlug, getWorktree, promptSelector, sessionPath } from "./utils" + +type TestFixtures = { + sdk: ReturnType + gotoSession: (sessionID?: string) => Promise +} + +type WorkerFixtures = { + directory: string + slug: string +} + +export const test = base.extend({ + directory: [ + async ({}, use) => { + const directory = await getWorktree() + await use(directory) + }, + { scope: "worker" }, + ], + slug: [ + async ({ directory }, use) => { + await use(dirSlug(directory)) + }, + { scope: "worker" }, + ], + sdk: async ({ directory }, use) => { + await use(createSdk(directory)) + }, + gotoSession: async ({ page, directory }, use) => { + const gotoSession = async (sessionID?: string) => { + await page.goto(sessionPath(directory, sessionID)) + await expect(page.locator(promptSelector)).toBeVisible() + } + await use(gotoSession) + }, +}) + +export { expect } diff --git a/packages/app/e2e/home.spec.ts b/packages/app/e2e/home.spec.ts index 5bb701076e..c6fb0e3b07 100644 --- a/packages/app/e2e/home.spec.ts +++ b/packages/app/e2e/home.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test" +import { test, expect } from "./fixtures" import { serverName } from "./utils" test("home renders and shows core entrypoints", async ({ page }) => { diff --git a/packages/app/e2e/navigation.spec.ts b/packages/app/e2e/navigation.spec.ts index 4d0d3b2b9d..76923af6ed 100644 --- a/packages/app/e2e/navigation.spec.ts +++ b/packages/app/e2e/navigation.spec.ts @@ -1,11 +1,9 @@ -import { test, expect } from "@playwright/test" -import { dirPath, dirSlug, getWorktree, promptSelector } from "./utils" - -test("project route redirects to /session", async ({ page }) => { - const directory = await getWorktree() - const slug = dirSlug(directory) +import { test, expect } from "./fixtures" +import { dirPath, promptSelector } from "./utils" +test("project route redirects to /session", async ({ page, directory, slug }) => { await page.goto(dirPath(directory)) + await expect(page).toHaveURL(new RegExp(`/${slug}/session`)) await expect(page.locator(promptSelector)).toBeVisible() }) diff --git a/packages/app/e2e/palette.spec.ts b/packages/app/e2e/palette.spec.ts index bad09aab96..617c55ac16 100644 --- a/packages/app/e2e/palette.spec.ts +++ b/packages/app/e2e/palette.spec.ts @@ -1,9 +1,8 @@ -import { test, expect } from "@playwright/test" -import { gotoSession, modKey, promptSelector } from "./utils" +import { test, expect } from "./fixtures" +import { modKey } from "./utils" -test("search palette opens and closes", async ({ page }) => { - await gotoSession(page) - await expect(page.locator(promptSelector)).toBeVisible() +test("search palette opens and closes", async ({ page, gotoSession }) => { + await gotoSession() await page.keyboard.press(`${modKey}+P`) diff --git a/packages/app/e2e/session.spec.ts b/packages/app/e2e/session.spec.ts index d44736a4f5..19e25a4213 100644 --- a/packages/app/e2e/session.spec.ts +++ b/packages/app/e2e/session.spec.ts @@ -1,9 +1,7 @@ -import { test, expect } from "@playwright/test" -import { createSdk, getWorktree, promptSelector, sessionPath } from "./utils" +import { test, expect } from "./fixtures" +import { promptSelector } from "./utils" -test("can open an existing session and type into the prompt", async ({ page }) => { - const directory = await getWorktree() - const sdk = createSdk(directory) +test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => { const title = `e2e smoke ${Date.now()}` const created = await sdk.session.create({ title }).then((r) => r.data) @@ -11,11 +9,9 @@ test("can open an existing session and type into the prompt", async ({ page }) = const sessionID = created.id try { - await page.goto(sessionPath(directory, sessionID)) + await gotoSession(sessionID) const prompt = page.locator(promptSelector) - await expect(prompt).toBeVisible() - await prompt.click() await page.keyboard.type("hello from e2e") await expect(prompt).toContainText("hello from e2e") diff --git a/packages/app/e2e/terminal.spec.ts b/packages/app/e2e/terminal.spec.ts index 8f90a3c0c1..fc558b6325 100644 --- a/packages/app/e2e/terminal.spec.ts +++ b/packages/app/e2e/terminal.spec.ts @@ -1,9 +1,8 @@ -import { test, expect } from "@playwright/test" -import { gotoSession, promptSelector, terminalSelector, terminalToggleKey } from "./utils" +import { test, expect } from "./fixtures" +import { terminalSelector, terminalToggleKey } from "./utils" -test("terminal panel can be toggled", async ({ page }) => { - await gotoSession(page) - await expect(page.locator(promptSelector)).toBeVisible() +test("terminal panel can be toggled", async ({ page, gotoSession }) => { + await gotoSession() const terminal = page.locator(terminalSelector) const initiallyOpen = await terminal.isVisible() diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts index c18b368029..eb0395950a 100644 --- a/packages/app/e2e/utils.ts +++ b/packages/app/e2e/utils.ts @@ -1,6 +1,5 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { base64Encode } from "@opencode-ai/util/encode" -import type { Page } from "@playwright/test" export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" @@ -37,9 +36,3 @@ export function dirPath(directory: string) { export function sessionPath(directory: string, sessionID?: string) { return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}` } - -export async function gotoSession(page: Page, sessionID?: string) { - const directory = await getWorktree() - await page.goto(sessionPath(directory, sessionID)) - return { directory, slug: dirSlug(directory) } -} From b90315bc7ee745f9afb81fb3cfab69b42980b79d Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 19 Jan 2026 05:28:15 -0600 Subject: [PATCH 10/42] chore: cleanup --- .github/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fa93b61cf6..fda848762f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,8 @@ jobs: uses: ./.github/actions/setup-bun - name: Install Playwright browsers - run: bunx --cwd packages/app playwright install --with-deps + working-directory: packages/app + run: bunx playwright install --with-deps - name: Seed opencode data run: bun --cwd packages/opencode script/seed-e2e.ts From 2b086f0584ad803f27bbf386b00b04ecdee204fe Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 19 Jan 2026 06:54:47 -0600 Subject: [PATCH 11/42] test(app): more e2e tests --- packages/app/e2e/context.spec.ts | 28 ++++++++++++++++++++++++++++ packages/app/e2e/file-open.spec.ts | 24 ++++++++++++++++++++++++ packages/app/e2e/sidebar.spec.ts | 20 ++++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 packages/app/e2e/context.spec.ts create mode 100644 packages/app/e2e/file-open.spec.ts create mode 100644 packages/app/e2e/sidebar.spec.ts diff --git a/packages/app/e2e/context.spec.ts b/packages/app/e2e/context.spec.ts new file mode 100644 index 0000000000..dbfeda8d8c --- /dev/null +++ b/packages/app/e2e/context.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from "./fixtures" +import { promptSelector } from "./utils" + +test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => { + const title = `e2e smoke context ${Date.now()}` + const created = await sdk.session.create({ title }).then((r) => r.data) + + if (!created?.id) throw new Error("Session create did not return an id") + const sessionID = created.id + + try { + await gotoSession(sessionID) + + const promptForm = page.locator("form").filter({ has: page.locator(promptSelector) }).first() + const contextButton = promptForm + .locator("button") + .filter({ has: promptForm.locator('[data-component="progress-circle"]').first() }) + .first() + + await expect(contextButton).toBeVisible() + await contextButton.click() + + const tabs = page.locator('[data-component="tabs"][data-variant="normal"]') + await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible() + } finally { + await sdk.session.delete({ sessionID }).catch(() => undefined) + } +}) diff --git a/packages/app/e2e/file-open.spec.ts b/packages/app/e2e/file-open.spec.ts new file mode 100644 index 0000000000..673caf9dc3 --- /dev/null +++ b/packages/app/e2e/file-open.spec.ts @@ -0,0 +1,24 @@ +import { test, expect } from "./fixtures" +import { modKey } from "./utils" + +test("can open a file tab from the search palette", async ({ page, gotoSession }) => { + await gotoSession() + + await page.keyboard.press(`${modKey}+P`) + + const dialog = page.getByRole("dialog") + await expect(dialog).toBeVisible() + + const input = dialog.getByRole("textbox").first() + await input.fill("package.json") + + const firstItem = dialog.locator('[data-slot="list-item"]').first() + await expect(firstItem).toBeVisible() + await firstItem.click() + + await expect(dialog).toHaveCount(0) + + const tabs = page.locator('[data-component="tabs"][data-variant="normal"]') + await expect(tabs).toBeVisible() + await expect(tabs.getByRole("tab").first()).toBeVisible() +}) diff --git a/packages/app/e2e/sidebar.spec.ts b/packages/app/e2e/sidebar.spec.ts new file mode 100644 index 0000000000..964b0a56c0 --- /dev/null +++ b/packages/app/e2e/sidebar.spec.ts @@ -0,0 +1,20 @@ +import { test, expect } from "./fixtures" +import { modKey } from "./utils" + +test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => { + await gotoSession() + + const createButton = page.getByRole("button", { name: /New (session|workspace)/ }).first() + const opened = (await createButton.count()) > 0 + + if (!opened) { + await page.keyboard.press(`${modKey}+B`) + await expect(createButton).toBeVisible() + } + + await page.keyboard.press(`${modKey}+B`) + await expect(createButton).toHaveCount(0) + + await page.keyboard.press(`${modKey}+B`) + await expect(createButton).toBeVisible() +}) From e9ede70793dae3f1f12b3e6144c0229b9ce96610 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 19 Jan 2026 07:00:00 -0600 Subject: [PATCH 12/42] chore: cleanup --- .github/workflows/test.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fda848762f..9cf83ca8df 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,8 @@ jobs: run: bunx playwright install --with-deps - name: Seed opencode data - run: bun --cwd packages/opencode script/seed-e2e.ts + working-directory: packages/opencode + run: bun script/seed-e2e.ts env: MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json OPENCODE_DISABLE_MODELS_FETCH: "true" @@ -42,7 +43,7 @@ jobs: OPENCODE_E2E_MODEL: "opencode/gpt-5-nano" - name: Run opencode server - run: bun --cwd packages/opencode run dev -- --print-logs --log-level WARN serve --port 4096 --hostname 0.0.0.0 & + run: bun run dev -- --print-logs --log-level WARN serve --port 4096 --hostname 0.0.0.0 & env: MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json OPENCODE_DISABLE_MODELS_FETCH: "true" From f00f18b926e21c5d48b8fdf5cb216888f1c52f82 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 19 Jan 2026 07:15:57 -0600 Subject: [PATCH 13/42] chore: cleanup --- turbo.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/turbo.json b/turbo.json index 6b1c9b3242..5de1b8d751 100644 --- a/turbo.json +++ b/turbo.json @@ -9,6 +9,10 @@ "opencode#test": { "dependsOn": ["^build"], "outputs": [] + }, + "@opencode-ai/app#test": { + "dependsOn": ["^build"], + "outputs": [] } } } From 1ba7c606e679bdcd1212c23a46146aed5646c504 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 19 Jan 2026 08:38:42 -0600 Subject: [PATCH 14/42] chore: cleanup --- packages/app/e2e/context.spec.ts | 25 +++++++++++++++++++++---- packages/app/e2e/file-open.spec.ts | 9 ++++----- packages/app/e2e/sidebar.spec.ts | 13 +++++++------ 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/packages/app/e2e/context.spec.ts b/packages/app/e2e/context.spec.ts index dbfeda8d8c..beabd2eb7d 100644 --- a/packages/app/e2e/context.spec.ts +++ b/packages/app/e2e/context.spec.ts @@ -9,12 +9,29 @@ test("context panel can be opened from the prompt", async ({ page, sdk, gotoSess const sessionID = created.id try { + await sdk.session.promptAsync({ + sessionID, + noReply: true, + parts: [ + { + type: "text", + text: "seed context", + }, + ], + }) + + await expect + .poll(async () => { + const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? []) + return messages.length + }) + .toBeGreaterThan(0) + await gotoSession(sessionID) - const promptForm = page.locator("form").filter({ has: page.locator(promptSelector) }).first() - const contextButton = promptForm - .locator("button") - .filter({ has: promptForm.locator('[data-component="progress-circle"]').first() }) + const contextButton = page + .locator('[data-component="button"]') + .filter({ has: page.locator('[data-component="progress-circle"]').first() }) .first() await expect(contextButton).toBeVisible() diff --git a/packages/app/e2e/file-open.spec.ts b/packages/app/e2e/file-open.spec.ts index 673caf9dc3..fb7104b6b0 100644 --- a/packages/app/e2e/file-open.spec.ts +++ b/packages/app/e2e/file-open.spec.ts @@ -12,13 +12,12 @@ test("can open a file tab from the search palette", async ({ page, gotoSession } const input = dialog.getByRole("textbox").first() await input.fill("package.json") - const firstItem = dialog.locator('[data-slot="list-item"]').first() - await expect(firstItem).toBeVisible() - await firstItem.click() + const fileItem = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first() + await expect(fileItem).toBeVisible() + await fileItem.click() await expect(dialog).toHaveCount(0) const tabs = page.locator('[data-component="tabs"][data-variant="normal"]') - await expect(tabs).toBeVisible() - await expect(tabs.getByRole("tab").first()).toBeVisible() + await expect(tabs.locator('[data-slot="tabs-trigger"]').first()).toBeVisible() }) diff --git a/packages/app/e2e/sidebar.spec.ts b/packages/app/e2e/sidebar.spec.ts index 964b0a56c0..925590f510 100644 --- a/packages/app/e2e/sidebar.spec.ts +++ b/packages/app/e2e/sidebar.spec.ts @@ -4,17 +4,18 @@ import { modKey } from "./utils" test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => { await gotoSession() - const createButton = page.getByRole("button", { name: /New (session|workspace)/ }).first() - const opened = (await createButton.count()) > 0 + const main = page.locator("main") + const closedClass = /xl:border-l/ + const isClosed = await main.evaluate((node) => node.className.includes("xl:border-l")) - if (!opened) { + if (isClosed) { await page.keyboard.press(`${modKey}+B`) - await expect(createButton).toBeVisible() + await expect(main).not.toHaveClass(closedClass) } await page.keyboard.press(`${modKey}+B`) - await expect(createButton).toHaveCount(0) + await expect(main).toHaveClass(closedClass) await page.keyboard.press(`${modKey}+B`) - await expect(createButton).toBeVisible() + await expect(main).not.toHaveClass(closedClass) }) From 3186e7ec7c059bba75fe498f47328ad757e7406c Mon Sep 17 00:00:00 2001 From: Github Action Date: Mon, 19 Jan 2026 14:59:54 +0000 Subject: [PATCH 15/42] Update node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index e51ed90463..fa91b3b310 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-DFsg3Dyt+cm8Z7CU6lpFd4gTAzydAewfiQW1T3qvDxA=", - "aarch64-linux": "sha256-0Im52dLeZ0ZtaPJr/U4m7+IRtOfziHNJI/Bu/V6cPho=", - "aarch64-darwin": "sha256-Rfd05h/BRLoM0SmKn+ui2RdXBNhkaVKkgBeT+uBs4J8=", - "x86_64-darwin": "sha256-CpZFHBMPJSib2Vqs6oC8HQjQtviPUMa/qezHAe22N/A=" + "x86_64-linux": "sha256-80+b7FwUy4mRWTzEjPrBWuR5Um67I1Rn4U/n/s/lBjs=", + "aarch64-linux": "sha256-xH/Grwh3b+HWsUkKN8LMcyMaMcmnIJYlgp38WJCat5E=", + "aarch64-darwin": "sha256-Izv6PE9gNaeYYfcqDwjTU/WYtD1y+j65annwvLzkMD8=", + "x86_64-darwin": "sha256-EG1Z0uAeyFiOeVsv0Sz1sa8/mdXuw/uvbYYrkFR3EAg=" } } From 843d76191e3d463cdf157cdcc6393000d44c7dfd Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 19 Jan 2026 10:12:46 -0500 Subject: [PATCH 16/42] zen: fix black reset date --- packages/console/core/src/util/date.test.ts | 20 ++++++++++++++++++++ packages/console/core/src/util/date.ts | 4 ++-- 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 packages/console/core/src/util/date.test.ts diff --git a/packages/console/core/src/util/date.test.ts b/packages/console/core/src/util/date.test.ts new file mode 100644 index 0000000000..074df8a2fa --- /dev/null +++ b/packages/console/core/src/util/date.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test" +import { getWeekBounds } from "./date" + +describe("util.date.getWeekBounds", () => { + test("returns a Monday-based week for Sunday dates", () => { + const date = new Date("2026-01-18T12:00:00Z") + const bounds = getWeekBounds(date) + + expect(bounds.start.toISOString()).toBe("2026-01-12T00:00:00.000Z") + expect(bounds.end.toISOString()).toBe("2026-01-19T00:00:00.000Z") + }) + + test("returns a seven day window", () => { + const date = new Date("2026-01-14T12:00:00Z") + const bounds = getWeekBounds(date) + + const span = bounds.end.getTime() - bounds.start.getTime() + expect(span).toBe(7 * 24 * 60 * 60 * 1000) + }) +}) diff --git a/packages/console/core/src/util/date.ts b/packages/console/core/src/util/date.ts index 7f34c9bb5e..9c1ab12d2c 100644 --- a/packages/console/core/src/util/date.ts +++ b/packages/console/core/src/util/date.ts @@ -1,7 +1,7 @@ export function getWeekBounds(date: Date) { - const dayOfWeek = date.getUTCDay() + const offset = (date.getUTCDay() + 6) % 7 const start = new Date(date) - start.setUTCDate(date.getUTCDate() - dayOfWeek + 1) + start.setUTCDate(date.getUTCDate() - offset) start.setUTCHours(0, 0, 0, 0) const end = new Date(start) end.setUTCDate(start.getUTCDate() + 7) From 31864cadb49fb356e98ec56b1021ec469972e82c Mon Sep 17 00:00:00 2001 From: Evgenii Kosenko <39831696+kedMertens@users.noreply.github.com> Date: Mon, 19 Jan 2026 17:50:41 +0200 Subject: [PATCH 17/42] docs: update codecompanion.nvim acp doc (#9411) --- packages/web/src/content/docs/acp.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/web/src/content/docs/acp.mdx b/packages/web/src/content/docs/acp.mdx index 9129db1359..43d89eae18 100644 --- a/packages/web/src/content/docs/acp.mdx +++ b/packages/web/src/content/docs/acp.mdx @@ -125,7 +125,7 @@ To use OpenCode as an ACP agent in [CodeCompanion.nvim](https://github.com/olimo ```lua require("codecompanion").setup({ - strategies = { + interactions = { chat = { adapter = { name = "opencode", @@ -138,7 +138,7 @@ require("codecompanion").setup({ This config sets up CodeCompanion to use OpenCode as the ACP agent for chat. -If you need to pass environment variables (like `OPENCODE_API_KEY`), refer to [Configuring Adapters: Environment Variables](https://codecompanion.olimorris.dev/configuration/adapters#environment-variables-setting-an-api-key) in the CodeCompanion.nvim documentation for full details. +If you need to pass environment variables (like `OPENCODE_API_KEY`), refer to [Configuring Adapters: Environment Variables](https://codecompanion.olimorris.dev/getting-started#setting-an-api-key) in the CodeCompanion.nvim documentation for full details. ## Support From 29e206b6c6df09efd369218017c0da2569793899 Mon Sep 17 00:00:00 2001 From: Vladimir Glafirov Date: Mon, 19 Jan 2026 16:51:27 +0100 Subject: [PATCH 18/42] docs: Improve Gitlab self-hosted instances documentation (#9391) --- packages/web/src/content/docs/providers.mdx | 62 ++++++++++++++++++++- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 6022d174a7..2a7d2ffb42 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -654,21 +654,77 @@ GitLab Duo provides AI-powered agentic chat with native tool calling capabilitie - **duo-chat-sonnet-4-5** - Balanced performance for most workflows - **duo-chat-opus-4-5** - Most capable for complex analysis +:::note +You can also specify 'GITLAB_TOKEN' environment variable if you don't want +to store token in opencode auth storage. +::: + ##### Self-Hosted GitLab +:::note[compliance note] +OpenCode uses a small model for some AI tasks like generating the session title. +It is configured to use gpt-5-nano by default, hosted by Zen. To lock OpenCode +to only use your own GitLab-hosted instance, add the following to your +`opencode.json` file. It is also recommended to disable session sharing. + +```json +{ + "$schema": "https://opencode.ai/config.json", + "small_model": "gitlab/duo-chat-haiku-4-5", + "share": "disabled" +} +``` + +::: + For self-hosted GitLab instances: ```bash -GITLAB_INSTANCE_URL=https://gitlab.company.com GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx opencode +export GITLAB_INSTANCE_URL=https://gitlab.company.com +export GITLAB_TOKEN=glpat-... +``` + +If your instance runs a custom AI Gateway: + +```bash +GITLAB_AI_GATEWAY_URL=https://ai-gateway.company.com ``` Or add to your bash profile: ```bash title="~/.bash_profile" export GITLAB_INSTANCE_URL=https://gitlab.company.com -export GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx +export GITLAB_AI_GATEWAY_URL=https://ai-gateway.company.com +export GITLAB_TOKEN=glpat-... ``` +:::note +Your GitLab administrator must enable the following: + +1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) for the user, group, or instance +2. Feature flags (via Rails console): + - `agent_platform_claude_code` + - `third_party_agents_enabled` + ::: + +##### OAuth for Self-Hosted instances + +In order to make Oauth working for your self-hosted instance, you need to create +a new application (Settings → Applications) with the +callback URL `http://127.0.0.1:8080/callback` and following scopes: + +- api (Access the API on your behalf) +- read_user (Read your personal information) +- read_repository (Allows read-only access to the repository) + +Then expose application ID as environment variable: + +```bash +export GITLAB_OAUTH_CLIENT_ID=your_application_id_here +``` + +More documentation on [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth) homepage. + ##### Configuration Customize through `opencode.json`: @@ -690,7 +746,7 @@ Customize through `opencode.json`: } ``` -##### GitLab API Tools (Optional) +##### GitLab API Tools (Optional, but highly recommended) To access GitLab tools (merge requests, issues, pipelines, CI/CD, etc.): From b1684f3d12eb64f3b4f1912c6e7151f1638822e8 Mon Sep 17 00:00:00 2001 From: paulclou <117023154+paulclou@users.noreply.github.com> Date: Mon, 19 Jan 2026 09:59:51 -0600 Subject: [PATCH 19/42] fix(config): rename uv formatter from 'uv format' to 'uv' for config consistency (#9409) Co-authored-by: Paul C. Lou --- packages/opencode/src/format/formatter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index 667a954c03..1939769d21 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -226,7 +226,7 @@ export const rlang: Info = { } export const uvformat: Info = { - name: "uv format", + name: "uv", command: ["uv", "format", "--", "$FILE"], extensions: [".py", ".pyi"], async enabled() { From 5b867246321ca0376a92641a748f22f82faa9bd3 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 19 Jan 2026 10:15:24 -0600 Subject: [PATCH 20/42] fix: cargo fmt actually does not support formatting single files --- packages/opencode/src/format/formatter.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index 1939769d21..1c58d5b8d1 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -347,13 +347,14 @@ export const rustfmt: Info = { }, } -export const cargofmt: Info = { - name: "cargofmt", - command: ["cargo", "fmt", "--", "$FILE"], - extensions: [".rs"], - async enabled() { - if (!Bun.which("cargo")) return false - const found = await Filesystem.findUp("Cargo.toml", Instance.directory, Instance.worktree) - return found.length > 0 - }, -} +// cargo fmt actually does not support formatting single files +// export const cargofmt: Info = { +// name: "cargofmt", +// command: ["cargo", "fmt", "--", "$FILE"], +// extensions: [".rs"], +// async enabled() { +// if (!Bun.which("cargo")) return false +// const found = await Filesystem.findUp("Cargo.toml", Instance.directory, Instance.worktree) +// return found.length > 0 +// }, +// } From 4ee540309f41d6eaa65d0d4dd7c70fea71c973d1 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:26:10 -0600 Subject: [PATCH 21/42] fix(app): hide settings button --- packages/app/src/pages/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index e803681801..39ca39b676 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1857,7 +1857,7 @@ export default function Layout(props: ParentProps) {
- + From 72cb7ccc00438625e58900efa780a004eda5a826 Mon Sep 17 00:00:00 2001 From: Joseph Campuzano Date: Mon, 19 Jan 2026 10:43:27 -0600 Subject: [PATCH 22/42] fix(app): list component jumping when mouse happens to be under the list and keyboard navigating. (#9435) --- packages/ui/src/components/list.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 631b3e33a2..bfe3440ea7 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -206,6 +206,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) setActive(props.key(item)) }} onMouseLeave={() => { + if (!store.mouseActive) return; setActive(null) }} > From 453417ed47774fc3077ef67c49c831b4014494f4 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 19 Jan 2026 16:46:09 +0000 Subject: [PATCH 23/42] chore: generate --- packages/ui/src/components/list.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index bfe3440ea7..b8a8f74607 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -206,7 +206,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) setActive(props.key(item)) }} onMouseLeave={() => { - if (!store.mouseActive) return; + if (!store.mouseActive) return setActive(null) }} > From d5ae8e0bef991f2b2ad9766b9ae2f1c903badab3 Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Mon, 19 Jan 2026 18:48:59 +0200 Subject: [PATCH 24/42] fix(opencode): `cargo fmt` is formatting whole workspace instead of edited file (#9436) --- packages/opencode/src/format/formatter.ts | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index 1c58d5b8d1..1a3aa1bb15 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -337,24 +337,6 @@ export const rustfmt: Info = { command: ["rustfmt", "$FILE"], extensions: [".rs"], async enabled() { - if (!Bun.which("rustfmt")) return false - const configs = ["rustfmt.toml", ".rustfmt.toml"] - for (const config of configs) { - const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree) - if (found.length > 0) return true - } - return false + return Bun.which("rustfmt") !== null }, } - -// cargo fmt actually does not support formatting single files -// export const cargofmt: Info = { -// name: "cargofmt", -// command: ["cargo", "fmt", "--", "$FILE"], -// extensions: [".rs"], -// async enabled() { -// if (!Bun.which("cargo")) return false -// const found = await Filesystem.findUp("Cargo.toml", Instance.directory, Instance.worktree) -// return found.length > 0 -// }, -// } From 1f11a8a6ea46867e2ad199c987bf14696a1b91d8 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:32:15 -0600 Subject: [PATCH 25/42] feat(app): improved session layout --- packages/app/src/pages/layout.tsx | 140 ++++++++++++------ packages/app/src/pages/session.tsx | 20 +-- .../enterprise/src/routes/share/[shareID].tsx | 21 +-- packages/ui/src/components/hover-card.css | 5 +- packages/ui/src/components/message-nav.css | 4 + packages/ui/src/components/message-nav.tsx | 24 ++- .../src/components/session-message-rail.css | 44 ------ .../src/components/session-message-rail.tsx | 46 ------ 8 files changed, 121 insertions(+), 183 deletions(-) delete mode 100644 packages/ui/src/components/session-message-rail.css delete mode 100644 packages/ui/src/components/session-message-rail.tsx diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 39ca39b676..2f71570f47 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -28,13 +28,14 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { InlineInput } from "@opencode-ai/ui/inline-input" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { HoverCard } from "@opencode-ai/ui/hover-card" +import { MessageNav } from "@opencode-ai/ui/message-nav" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { Spinner } from "@opencode-ai/ui/spinner" import { Dialog } from "@opencode-ai/ui/dialog" import { getFilename } from "@opencode-ai/util/path" -import { Session } from "@opencode-ai/sdk/v2/client" +import { Session, type Message, type TextPart } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" import { createStore, produce, reconcile } from "solid-js/store" import { @@ -1329,63 +1330,104 @@ export default function Layout(props: ParentProps) { return agent?.color }) + const hoverMessages = createMemo(() => + sessionStore.message[props.session.id]?.filter((message) => message.role === "user"), + ) + const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined) + const hoverAllowed = createMemo(() => !props.mobile && layout.sidebar.opened()) + const isActive = createMemo(() => props.session.id === params.id) + + const messageLabel = (message: Message) => { + const parts = sessionStore.part[message.id] ?? [] + const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored) + return text?.text + } + + const item = ( + prefetchSession(props.session, "high")} + onFocus={() => prefetchSession(props.session, "high")} + > +
+
+ }> + + + + +
+ + +
+ + 0}> +
+ + +
+ + props.session.title} + onSave={(next) => renameSession(props.session, next)} + class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" + displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" + stopPropagation + /> + + + {(summary) => ( +
+ +
+ )} +
+
+
+ )) + return (
- prefetchSession(props.session, "high")} - onFocus={() => prefetchSession(props.session, "high")} + -
-
- }> - - - - -
- - -
- - 0}> -
- - -
- - props.session.title} - onSave={(next) => renameSession(props.session, next)} - class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" - displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" - stopPropagation + + Loading messages…
}> + { + if (!isActive()) { + navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`) + return + } + window.location.hash = `message-${message.id}` + window.dispatchEvent(new HashChangeEvent("hashchange")) + }} + size="normal" + class="w-60" /> - - - {(summary) => ( -
- -
- )}
-
-
+ +
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index f063ce35b4..b1c844f0c6 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -18,7 +18,6 @@ import { useCodeComponent } from "@opencode-ai/ui/context/code" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { createAutoScroll } from "@opencode-ai/ui/hooks" import { SessionReview } from "@opencode-ai/ui/session-review" -import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" @@ -1163,17 +1162,6 @@ export default function Page() { } >
- -
- -
-
{ @@ -1255,13 +1243,7 @@ export default function Page() { root: "min-w-0 w-full relative", content: "flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]", - container: - "px-4 md:px-6 " + - (!showTabs() - ? "md:max-w-200 md:mx-auto" - : visibleUserMessages().length > 1 - ? "md:pr-6 md:pl-18" - : ""), + container: "w-full px-4 md:px-6", }} />
diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index 776b422645..d657ddc122 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -16,7 +16,6 @@ import { iife } from "@opencode-ai/util/iife" import { Binary } from "@opencode-ai/util/binary" import { NamedError } from "@opencode-ai/util/error" import { DateTime } from "luxon" -import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail" import { createStore } from "solid-js/store" import z from "zod" import NotFound from "../[...404]" @@ -353,26 +352,16 @@ export default function () {
1, - "px-6": !wide() && messages().length === 1, + "w-full flex justify-start items-start min-w-0 px-6": true, }} > {title()}
- 1 - ? "pr-6 pl-18" - : "px-6"), + container: "w-full pb-20 px-6", }} >
void + getLabel?: (message: UserMessage) => string | undefined }, ) { - const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect"]) + const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect", "getLabel"]) const content = () => (
    @@ -19,23 +20,36 @@ export function MessageNav( {(message) => { const handleClick = () => local.onMessageSelect(message) + const handleKeyPress = (event: KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + local.onMessageSelect(message) + } + return (
  • -
    +
    - diff --git a/packages/ui/src/components/session-message-rail.css b/packages/ui/src/components/session-message-rail.css deleted file mode 100644 index 9f248bed25..0000000000 --- a/packages/ui/src/components/session-message-rail.css +++ /dev/null @@ -1,44 +0,0 @@ -[data-component="session-message-rail"] { - display: contents; -} - -[data-slot="session-message-rail-compact"], -[data-slot="session-message-rail-full"] { - position: absolute; - left: 1.5rem; - margin-top: 0.625rem; - top: 0; - bottom: 8rem; - overflow-y: auto; -} - -[data-slot="session-message-rail-compact"] { - display: flex; -} - -[data-slot="session-message-rail-full"] { - display: none; -} - -@container (min-width: 88rem) { - [data-slot="session-message-rail-compact"] { - display: none; - } - [data-slot="session-message-rail-full"] { - display: flex; - } -} - -[data-component="session-message-rail"] [data-slot="session-message-rail-full"] { - transform: none; -} - -[data-component="session-message-rail"][data-wide] [data-slot="session-message-rail-full"] { - margin-top: 0.125rem; - left: calc(((100% - min(100%, 50rem)) / 2) - 1.5rem); - transform: translateX(-100%); -} - -[data-component="session-message-rail"]:not([data-wide]) [data-slot="session-message-rail-full"] { - margin-top: 0.625rem; -} diff --git a/packages/ui/src/components/session-message-rail.tsx b/packages/ui/src/components/session-message-rail.tsx deleted file mode 100644 index 1935a4f930..0000000000 --- a/packages/ui/src/components/session-message-rail.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { UserMessage } from "@opencode-ai/sdk/v2" -import { ComponentProps, Show, splitProps } from "solid-js" -import { MessageNav } from "./message-nav" -import "./session-message-rail.css" - -export interface SessionMessageRailProps extends ComponentProps<"div"> { - messages: UserMessage[] - current?: UserMessage - wide?: boolean - onMessageSelect: (message: UserMessage) => void -} - -export function SessionMessageRail(props: SessionMessageRailProps) { - const [local, others] = splitProps(props, ["messages", "current", "wide", "onMessageSelect", "class", "classList"]) - - return ( - 1}> -
    -
    - -
    -
    - -
    -
    -
    - ) -} From befd0f16362678dcd99cd9118cbcb044997c9511 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 15 Jan 2026 15:05:40 -0600 Subject: [PATCH 26/42] feat(app): new session layout --- packages/app/src/pages/layout.tsx | 53 +++++----- packages/app/src/pages/session.tsx | 4 +- .../enterprise/src/routes/share/[shareID].tsx | 4 +- packages/ui/src/components/session-turn.css | 28 ++---- packages/ui/src/components/session-turn.tsx | 96 ++++++++++++------- 5 files changed, 107 insertions(+), 78 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 2f71570f47..5312ff0a2a 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1296,7 +1296,13 @@ export default function Layout(props: ParentProps) { ) } - const SessionItem = (props: { session: Session; slug: string; mobile?: boolean; dense?: boolean }): JSX.Element => { + const SessionItem = (props: { + session: Session + slug: string + mobile?: boolean + dense?: boolean + popover?: boolean + }): JSX.Element => { const notification = useNotification() const notifications = createMemo(() => notification.session.unseen(props.session.id)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) @@ -1335,6 +1341,7 @@ export default function Layout(props: ParentProps) { ) const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined) const hoverAllowed = createMemo(() => !props.mobile && layout.sidebar.opened()) + const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) const isActive = createMemo(() => props.session.id === params.id) const messageLabel = (message: Message) => { @@ -1370,23 +1377,14 @@ export default function Layout(props: ParentProps) {
    - - props.session.title} - onSave={(next) => renameSession(props.session, next)} - class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" - displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" - stopPropagation - /> - + props.session.title} + onSave={(next) => renameSession(props.session, next)} + class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" + displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" + stopPropagation + /> {(summary) => (
    @@ -1396,7 +1394,7 @@ export default function Layout(props: ParentProps) {
    - )) + ) return (
    + {item} + + } > Loading messages…
    }> @@ -1730,6 +1732,7 @@ export default function Layout(props: ParentProps) { slug={base64Encode(props.project.worktree)} dense mobile={props.mobile} + popover={false} /> )} @@ -1746,7 +1749,13 @@ export default function Layout(props: ParentProps) {
    {(session) => ( - + )}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index b1c844f0c6..5f282ac857 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1233,6 +1233,7 @@ export default function Page() { > diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index d657ddc122..483db4d932 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -295,13 +295,13 @@ export default function () { {(message) => ( setStore("expandedSteps", message.id, (v) => !v)} classes={{ root: "min-w-0 w-full relative", - content: - "flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]", + content: "flex flex-col justify-between !overflow-visible", container: "px-4", }} /> diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 1e3cc0b292..f7ab971794 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -29,23 +29,6 @@ gap: 28px; overflow-anchor: none; - [data-slot="session-turn-user-badges"] { - position: absolute; - right: 0; - display: flex; - gap: 6px; - padding-left: 16px; - background: linear-gradient(to right, transparent, var(--background-stronger) 12px); - opacity: 0; - transition: opacity 0.15s ease; - pointer-events: none; - } - - &:hover [data-slot="session-turn-user-badges"] { - opacity: 1; - pointer-events: auto; - } - [data-slot="session-turn-badge"] { display: inline-flex; align-items: center; @@ -71,7 +54,7 @@ [data-slot="session-turn-response-trigger"] { position: sticky; - top: 32px; + top: calc(var(--sticky-header-height, 0px)); background-color: var(--background-stronger); z-index: 20; width: calc(100% + 9px); @@ -88,10 +71,17 @@ } [data-slot="session-turn-message-content"] { - margin-top: -18px; + margin-top: 0; max-width: 100%; } + [data-slot="session-turn-user-badges"] { + display: flex; + align-items: center; + gap: 6px; + padding-left: 16px; + } + [data-slot="session-turn-message-title"] { width: 100%; font-size: var(--font-size-large); diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index ae1321bac1..8b807af82b 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -119,6 +119,7 @@ function AssistantMessageItem(props: { export function SessionTurn( props: ParentProps<{ sessionID: string + sessionTitle?: string messageID: string lastUserMessageID?: string stepsExpanded?: boolean @@ -330,7 +331,9 @@ export function SessionTurn( const response = createMemo(() => lastTextPart()?.text) const responsePartId = createMemo(() => lastTextPart()?.id) - const hasDiffs = createMemo(() => message()?.summary?.diffs?.length) + const sessionInfo = createMemo(() => data.store.session.find((item) => item.id === props.sessionID)) + const sessionTitle = createMemo(() => props.sessionTitle ?? sessionInfo()?.title) + const hasDiffs = createMemo(() => (data.store.session_diff?.[props.sessionID]?.length ?? 0) > 0) const hideResponsePart = createMemo(() => !working() && !!responsePartId()) const [responseCopied, setResponseCopied] = createSignal(false) @@ -376,6 +379,7 @@ export function SessionTurn( diffLimit: diffInit, status: rawStatus(), duration: duration(), + titleShown: false, }) createEffect( @@ -389,6 +393,18 @@ export function SessionTurn( ), ) + createEffect(() => { + if (!sessionTitle()) { + setStore("titleShown", false) + return + } + if (store.titleShown) return + const first = allMessages().find((item) => item?.role === "user") + if (!first) return + if (first.id !== props.messageID) return + setStore("titleShown", true) + }) + createEffect(() => { const r = retry() if (!r) { @@ -482,40 +498,53 @@ export function SessionTurn( - {/* Title (sticky) */} -
setStore("stickyTitleRef", el)} data-slot="session-turn-sticky-title"> -
-
- - - - - -

{msg().summary?.title}

-
-
-
-
- - {(msg() as UserMessage).agent} - - - - - {(msg() as UserMessage).model?.modelID} - - - {(msg() as UserMessage).variant || "default"} + +
setStore("stickyTitleRef", el)} data-slot="session-turn-sticky-title"> +
+
+ + + + + +

{sessionTitle()}

+
+
+
-
+ + + +
+ + {(msg() as UserMessage).agent} + + + + + {(msg() as UserMessage).model?.modelID} + + + + {(msg() as UserMessage).variant} + +
+
{/* User Message */}
+ {/* Trigger (sticky) */}
setStore("stickyTriggerRef", el)} data-slot="session-turn-response-trigger"> @@ -612,7 +641,7 @@ export function SessionTurn( setStore("diffsOpen", value) }} > - + {(diff) => ( @@ -658,13 +687,13 @@ export function SessionTurn( )} - store.diffLimit}> + store.diffLimit}>
From 7811e01c8efc57d56b91547463c707baf2eb6815 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 18 Jan 2026 05:26:24 -0600 Subject: [PATCH 27/42] fix(app): new layout improvements --- packages/app/src/pages/session.tsx | 135 ++++++++++------ packages/ui/src/components/message-part.tsx | 35 ++++- packages/ui/src/components/session-turn.css | 95 +++++++++--- packages/ui/src/components/session-turn.tsx | 161 +++++--------------- 4 files changed, 239 insertions(+), 187 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 5f282ac857..31f9eac9c2 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -18,6 +18,7 @@ import { useCodeComponent } from "@opencode-ai/ui/context/code" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { createAutoScroll } from "@opencode-ai/ui/hooks" import { SessionReview } from "@opencode-ai/ui/session-review" +import { Mark } from "@opencode-ai/ui/logo" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" @@ -787,17 +788,14 @@ export default function Page() { .filter((tab) => tab !== "context"), ) - const reviewTab = createMemo(() => hasReview() || tabs().active() === "review") - const mobileReview = createMemo(() => !isDesktop() && hasReview() && store.mobileTab === "review") + const mobileReview = createMemo(() => !isDesktop() && view().reviewPanel.opened() && store.mobileTab === "review") - const showTabs = createMemo( - () => view().reviewPanel.opened() && (hasReview() || tabs().all().length > 0 || contextOpen()), - ) + const showTabs = createMemo(() => view().reviewPanel.opened()) const activeTab = createMemo(() => { const active = tabs().active() if (active) return active - if (reviewTab()) return "review" + if (hasReview()) return "review" const first = openedTabs()[0] if (first) return first @@ -1095,8 +1093,8 @@ export default function Page() {
- {/* Mobile tab bar - only shown on mobile when there are diffs */} - + {/* Mobile tab bar - only shown on mobile when user opened review */} + setStore("mobileTab", "review")} > - {reviewCount()} Files Changed + + {reviewCount()} Files Changed + Review + @@ -1138,26 +1139,36 @@ export default function Page() { when={!mobileReview()} fallback={
- Loading changes...
} - > - { - const value = file.tab(path) - tabs().open(value) - file.load(path) - }} - classes={{ - root: "pb-[calc(var(--prompt-height,8rem)+24px)]", - header: "px-4", - container: "px-4", - }} - /> -
+ + + Loading changes...
} + > + { + const value = file.tab(path) + tabs().open(value) + file.load(path) + }} + classes={{ + root: "pb-[calc(var(--prompt-height,8rem)+32px)]", + header: "px-4", + container: "px-4", + }} + /> + + + +
+ +
No changes in this session yet.
+
+
+
} > @@ -1170,11 +1181,29 @@ export default function Page() { }} onClick={autoScroll.handleInteraction} class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar" + style={{ "--session-title-height": info()?.title ? "40px" : "0px" }} > + +
+
+

{info()?.title}

+
+
+
+
- +
@@ -1386,26 +1415,36 @@ export default function Page() {
- +
- Loading changes...
} - > - { - const value = file.tab(path) - tabs().open(value) - file.load(path) - }} - /> -
+ + + Loading changes...
} + > + { + const value = file.tab(path) + tabs().open(value) + file.load(path) + }} + /> +
+ + +
+ +
No changes in this session yet.
+
+
+
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 47403786b2..b3fd01c2d8 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -46,6 +46,7 @@ import { checksum } from "@opencode-ai/util/encode" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" import { createAutoScroll } from "../hooks" +import { createResizeObserver } from "@solid-primitives/resize-observer" interface Diagnostic { range: { @@ -297,6 +298,23 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage; part export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) { const dialog = useDialog() const [copied, setCopied] = createSignal(false) + const [expanded, setExpanded] = createSignal(false) + const [canExpand, setCanExpand] = createSignal(false) + let textRef: HTMLDivElement | undefined + + const updateCanExpand = () => { + const el = textRef + if (!el) return + if (expanded()) return + setCanExpand(el.scrollHeight > el.clientHeight + 2) + } + + createResizeObserver( + () => textRef, + () => { + updateCanExpand() + }, + ) const textPart = createMemo( () => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined, @@ -304,6 +322,11 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp const text = createMemo(() => textPart()?.text || "") + createEffect(() => { + text() + updateCanExpand() + }) + const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? []) const attachments = createMemo(() => @@ -335,7 +358,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp } return ( -
+
0}>
@@ -365,8 +388,16 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
-
+
(textRef = el)}> +
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index f7ab971794..a3c87c576d 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -44,23 +44,33 @@ } } - [data-slot="session-turn-sticky-title"] { - width: 100%; + [data-slot="session-turn-sticky"] { + width: calc(100% + 9px); position: sticky; - top: 0; + top: var(--session-title-height, 0px); + z-index: 20; background-color: var(--background-stronger); - z-index: 21; + margin-left: -9px; + padding-left: 9px; + padding-bottom: 12px; + display: flex; + flex-direction: column; + gap: 12px; + + &::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-color: var(--background-stronger); + z-index: -1; + } } [data-slot="session-turn-response-trigger"] { - position: sticky; - top: calc(var(--sticky-header-height, 0px)); - background-color: var(--background-stronger); - z-index: 20; - width: calc(100% + 9px); - margin-left: -9px; - padding-left: 9px; - padding-bottom: 8px; + width: fit-content; } [data-slot="session-turn-message-header"] { @@ -75,6 +85,61 @@ max-width: 100%; } + [data-component="user-message"] [data-slot="user-message-text"] { + max-height: var(--user-message-collapsed-height, 64px); + } + + [data-component="user-message"][data-expanded="true"] [data-slot="user-message-text"] { + max-height: none; + } + + [data-component="user-message"][data-can-expand="true"] [data-slot="user-message-text"] { + padding-right: 36px; + padding-bottom: 28px; + } + + [data-component="user-message"] [data-slot="user-message-text"] [data-slot="user-message-expand"] { + display: none; + position: absolute; + bottom: 6px; + right: 6px; + padding: 0; + } + + [data-component="user-message"][data-can-expand="true"] + [data-slot="user-message-text"] + [data-slot="user-message-expand"], + [data-component="user-message"][data-expanded="true"] + [data-slot="user-message-text"] + [data-slot="user-message-expand"] { + display: inline-flex; + align-items: center; + justify-content: center; + height: 22px; + width: 22px; + border: none; + border-radius: 6px; + background: transparent; + cursor: pointer; + color: var(--text-weak); + + [data-slot="icon-svg"] { + transition: transform 0.15s ease; + } + } + + [data-component="user-message"][data-expanded="true"] + [data-slot="user-message-text"] + [data-slot="user-message-expand"] + [data-slot="icon-svg"] { + transform: rotate(180deg); + } + + [data-component="user-message"] [data-slot="user-message-text"] [data-slot="user-message-expand"]:hover { + background: var(--surface-raised-base); + color: var(--text-base); + } + [data-slot="session-turn-user-badges"] { display: flex; align-items: center; @@ -266,11 +331,7 @@ } [data-component="sticky-accordion-header"] { - top: var(--sticky-header-height, 40px); - - &[data-expanded]::before { - top: calc(-1 * var(--sticky-header-height, 40px)); - } + position: static; } [data-slot="session-turn-accordion-trigger-content"] { diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 8b807af82b..e5fe4ba1ce 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -13,17 +13,13 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path" import { Binary } from "@opencode-ai/util/binary" import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js" -import { createResizeObserver } from "@solid-primitives/resize-observer" import { DiffChanges } from "./diff-changes" -import { Typewriter } from "./typewriter" import { Message, Part } from "./message-part" import { Markdown } from "./markdown" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { FileIcon } from "./file-icon" import { Icon } from "./icon" -import { ProviderIcon } from "./provider-icon" -import type { IconName } from "./provider-icons/types" import { IconButton } from "./icon-button" import { Tooltip } from "./tooltip" import { Card } from "./card" @@ -331,8 +327,6 @@ export function SessionTurn( const response = createMemo(() => lastTextPart()?.text) const responsePartId = createMemo(() => lastTextPart()?.id) - const sessionInfo = createMemo(() => data.store.session.find((item) => item.id === props.sessionID)) - const sessionTitle = createMemo(() => props.sessionTitle ?? sessionInfo()?.title) const hasDiffs = createMemo(() => (data.store.session_diff?.[props.sessionID]?.length ?? 0) > 0) const hideResponsePart = createMemo(() => !working() && !!responsePartId()) @@ -371,15 +365,11 @@ export function SessionTurn( const diffBatch = 20 const [store, setStore] = createStore({ - stickyTitleRef: undefined as HTMLDivElement | undefined, - stickyTriggerRef: undefined as HTMLDivElement | undefined, - stickyHeaderHeight: 0, retrySeconds: 0, diffsOpen: [] as string[], diffLimit: diffInit, status: rawStatus(), duration: duration(), - titleShown: false, }) createEffect( @@ -393,18 +383,6 @@ export function SessionTurn( ), ) - createEffect(() => { - if (!sessionTitle()) { - setStore("titleShown", false) - return - } - if (store.titleShown) return - const first = allMessages().find((item) => item?.role === "user") - if (!first) return - if (first.id !== props.messageID) return - setStore("titleShown", true) - }) - createEffect(() => { const r = retry() if (!r) { @@ -420,22 +398,6 @@ export function SessionTurn( onCleanup(() => clearInterval(timer)) }) - createResizeObserver( - () => store.stickyTitleRef, - ({ height }) => { - const triggerHeight = store.stickyTriggerRef?.offsetHeight ?? 0 - setStore("stickyHeaderHeight", height + triggerHeight + 8) - }, - ) - - createResizeObserver( - () => store.stickyTriggerRef, - ({ height }) => { - const titleHeight = store.stickyTitleRef?.offsetHeight ?? 0 - setStore("stickyHeaderHeight", titleHeight + height + 8) - }, - ) - createEffect(() => { const timer = setInterval(() => { setStore("duration", duration()) @@ -491,99 +453,58 @@ export function SessionTurn( data-message={msg().id} data-slot="session-turn-message-container" class={props.classes?.container} - style={{ "--sticky-header-height": `${store.stickyHeaderHeight}px` }} > - -
setStore("stickyTitleRef", el)} data-slot="session-turn-sticky-title"> -
-
+
+ {/* User Message */} +
+ +
+ + {/* Trigger (sticky) */} + +
+
+ · + {store.duration} + 0}> + + +
-
- - - -
- - {(msg() as UserMessage).agent} - - - - - {(msg() as UserMessage).model?.modelID} - - - - {(msg() as UserMessage).variant} - -
-
- {/* User Message */} -
- +
- - {/* Trigger (sticky) */} - -
setStore("stickyTriggerRef", el)} data-slot="session-turn-response-trigger"> - -
-
{/* Response */} 0}>
From c720a2163c38e7ac08cd130fbba84772ccb582b6 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:38:41 -0600 Subject: [PATCH 28/42] chore: cleanup --- packages/ui/src/components/session-turn.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index a3c87c576d..556b357c3b 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -331,7 +331,11 @@ } [data-component="sticky-accordion-header"] { - position: static; + top: var(--sticky-header-height, 40px); + &[data-expanded]::before { + top: calc(-1 * var(--sticky-header-height, 40px)); + } + /* position: static; */ } [data-slot="session-turn-accordion-trigger-content"] { From eb779a7cc5c1728471489a3304a6203716bd4d47 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:54:56 -0600 Subject: [PATCH 29/42] chore: cleanup --- packages/ui/src/components/session-turn.css | 13 +++-- packages/ui/src/components/session-turn.tsx | 60 +++++++++++++++++++-- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 556b357c3b..5f8c0a16f6 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -1,4 +1,6 @@ [data-component="session-turn"] { + --session-turn-sticky-height: 0px; + --sticky-header-height: calc(var(--session-title-height, 0px) + var(--session-turn-sticky-height, 0px) + 12px); /* flex: 1; */ height: 100%; min-height: 0; @@ -44,6 +46,12 @@ } } + [data-slot="session-turn-attachments"] { + width: 100%; + min-width: 0; + align-self: stretch; + } + [data-slot="session-turn-sticky"] { width: calc(100% + 9px); position: sticky; @@ -331,11 +339,10 @@ } [data-component="sticky-accordion-header"] { - top: var(--sticky-header-height, 40px); + top: var(--sticky-header-height, 0px); &[data-expanded]::before { - top: calc(-1 * var(--sticky-header-height, 40px)); + top: calc(-1 * var(--sticky-header-height, 0px)); } - /* position: static; */ } [data-slot="session-turn-accordion-trigger-content"] { diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index e5fe4ba1ce..a918f0ae4f 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -1,5 +1,6 @@ import { AssistantMessage, + FilePart, Message as MessageType, Part as PartType, type PermissionRequest, @@ -29,6 +30,7 @@ import { Spinner } from "./spinner" import { createStore } from "solid-js/store" import { DateTime, DurationUnit, Interval } from "luxon" import { createAutoScroll } from "../hooks" +import { createResizeObserver } from "@solid-primitives/resize-observer" function computeStatusFromPart(part: PartType | undefined): string | undefined { if (!part) return undefined @@ -75,6 +77,12 @@ function same(a: readonly T[], b: readonly T[]) { return a.every((x, i) => x === b[i]) } +function isAttachment(part: PartType | undefined) { + if (part?.type !== "file") return false + const mime = (part as FilePart).mime ?? "" + return mime.startsWith("image/") || mime === "application/pdf" +} + function AssistantMessageItem(props: { message: AssistantMessage responsePartId: string | undefined @@ -133,6 +141,7 @@ export function SessionTurn( const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] + const emptyFiles: FilePart[] = [] const emptyAssistant: AssistantMessage[] = [] const emptyPermissions: PermissionRequest[] = [] const emptyPermissionParts: { part: ToolPart; message: AssistantMessage }[] = [] @@ -180,6 +189,19 @@ export function SessionTurn( return data.store.part[msg.id] ?? emptyParts }) + const attachmentParts = createMemo(() => { + const msgParts = parts() + if (msgParts.length === 0) return emptyFiles + return msgParts.filter((part) => isAttachment(part)) as FilePart[] + }) + + const stickyParts = createMemo(() => { + const msgParts = parts() + if (msgParts.length === 0) return emptyParts + if (attachmentParts().length === 0) return msgParts + return msgParts.filter((part) => !isAttachment(part)) + }) + const assistantMessages = createMemo( () => { const msg = message() @@ -331,6 +353,15 @@ export function SessionTurn( const hideResponsePart = createMemo(() => !working() && !!responsePartId()) const [responseCopied, setResponseCopied] = createSignal(false) + const [rootRef, setRootRef] = createSignal() + const [stickyRef, setStickyRef] = createSignal() + + const updateStickyHeight = (height: number) => { + const root = rootRef() + if (!root) return + const next = Math.ceil(height) + root.style.setProperty("--session-turn-sticky-height", `${next}px`) + } const handleCopyResponse = async () => { const content = response() if (!content) return @@ -361,6 +392,24 @@ export function SessionTurn( onUserInteracted: props.onUserInteracted, }) + createResizeObserver( + () => stickyRef(), + ({ height }) => { + updateStickyHeight(height) + }, + ) + + createEffect(() => { + const root = rootRef() + if (!root) return + const sticky = stickyRef() + if (!sticky) { + root.style.setProperty("--session-turn-sticky-height", "0px") + return + } + updateStickyHeight(sticky.getBoundingClientRect().height) + }) + const diffInit = 20 const diffBatch = 20 @@ -438,7 +487,7 @@ export function SessionTurn( }) return ( -
+
-
+ 0}> +
+ +
+
+
{/* User Message */}
- +
{/* Trigger (sticky) */} From c7f0cb3d2d516e1a4673df1cf5be4ee983a78bfb Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 19 Jan 2026 15:20:28 +0000 Subject: [PATCH 30/42] fix: remove focus outline from dropdown menu --- packages/ui/src/components/dropdown-menu.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/ui/src/components/dropdown-menu.css b/packages/ui/src/components/dropdown-menu.css index d2dc03c500..cba041613e 100644 --- a/packages/ui/src/components/dropdown-menu.css +++ b/packages/ui/src/components/dropdown-menu.css @@ -11,6 +11,11 @@ z-index: 50; transform-origin: var(--kb-menu-content-transform-origin); + &:focus, + &:focus-visible { + outline: none; + } + &[data-closed] { animation: dropdown-menu-close 0.15s ease-out; } From 89be504abcb74093342e1ead17d0b7bce5a44ba8 Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 19 Jan 2026 15:43:50 +0000 Subject: [PATCH 31/42] update: align edit project dialog padding and avatar styles --- .../app/src/components/dialog-edit-project.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index 2f0f7db1f6..8160821b22 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -78,7 +78,7 @@ export function DialogEditProject(props: { project: LocalProject }) { return ( -
+
( )} From e12b94d91aee643dc04048a11412cd2a88bddd9e Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 19 Jan 2026 15:45:13 +0000 Subject: [PATCH 32/42] update: adjust edit project icon helper text --- packages/app/src/components/dialog-edit-project.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index 8160821b22..a2a0ba9db6 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -130,9 +130,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
-
- Click or drag an image - Recommended: 128x128px +
+ Recommended size 128x128px
From 494e8d5be94c72950476027d08fc6a2faafb5f94 Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 19 Jan 2026 15:57:47 +0000 Subject: [PATCH 33/42] update: tweak edit project icon container --- packages/app/src/components/dialog-edit-project.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index a2a0ba9db6..d47dd9dcc4 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -94,10 +94,11 @@ export function DialogEditProject(props: { project: LocalProject }) {
Date: Mon, 19 Jan 2026 15:59:11 +0000 Subject: [PATCH 34/42] update: constrain edit project dialog width --- packages/app/src/components/dialog-edit-project.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index d47dd9dcc4..81ceb305db 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -77,7 +77,7 @@ export function DialogEditProject(props: { project: LocalProject }) { } return ( - +
Date: Mon, 19 Jan 2026 16:00:21 +0000 Subject: [PATCH 35/42] update: tighten edit project color spacing --- packages/app/src/components/dialog-edit-project.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index 81ceb305db..f6e07df607 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -140,7 +140,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
-
+
{(color) => ( - +
+ +
+
+ +
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 2d680b28bb..98f96c8e80 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -64,6 +64,8 @@ const icons = { help: ``, "settings-gear": ``, dash: ``, + "cloud-upload": ``, + trash: ``, } export interface IconProps extends ComponentProps<"svg"> { From b72a00eaa32e0655d6292ff5f3f79f0ce9231420 Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 19 Jan 2026 17:10:18 +0000 Subject: [PATCH 37/42] fix text field border showing through focus ring --- packages/ui/src/components/text-field.css | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/components/text-field.css b/packages/ui/src/components/text-field.css index a739c4eb21..15f5fd4fbb 100644 --- a/packages/ui/src/components/text-field.css +++ b/packages/ui/src/components/text-field.css @@ -52,6 +52,7 @@ background: var(--input-base); &:focus-within { + border-color: transparent; /* border/shadow-xs/select */ box-shadow: 0 0 0 3px var(--border-weak-selected), From dd0906be8c52d460d856ed4cf89e5fe193aa06db Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 19 Jan 2026 11:21:54 -0600 Subject: [PATCH 38/42] tweak: apply patch description --- packages/opencode/src/tool/apply_patch.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/apply_patch.txt b/packages/opencode/src/tool/apply_patch.txt index e195cd9cb1..5b2d95608c 100644 --- a/packages/opencode/src/tool/apply_patch.txt +++ b/packages/opencode/src/tool/apply_patch.txt @@ -1,4 +1,4 @@ -Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON. Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope: +Use the `apply_patch` tool to edit files. Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope: *** Begin Patch [ one or more file sections ] From fc50b2962c24dc37fa131759cd56460fbc1f43fa Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 19 Jan 2026 11:28:18 -0600 Subject: [PATCH 39/42] fix(app): make terminal sessions scoped to workspace --- packages/app/src/context/terminal.tsx | 57 ++++++++++++++++----------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index a7753069cf..709d7b899a 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -25,11 +25,11 @@ type TerminalCacheEntry = { dispose: VoidFunction } -function createTerminalSession(sdk: ReturnType, dir: string, id: string | undefined) { - const legacy = `${dir}/terminal${id ? "/" + id : ""}.v1` +function createTerminalSession(sdk: ReturnType, dir: string, session?: string) { + const legacy = session ? [`${dir}/terminal/${session}.v1`, `${dir}/terminal.v1`] : [`${dir}/terminal.v1`] const [store, setStore, _, ready] = persisted( - Persist.scoped(dir, id, "terminal", [legacy]), + Persist.workspace(dir, "terminal", legacy), createStore<{ active?: string all: LocalPTY[] @@ -43,17 +43,28 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: all: createMemo(() => Object.values(store.all)), active: createMemo(() => store.active), new() { + const parse = (title: string) => { + const match = title.match(/^Terminal (\d+)$/) + if (!match) return + const value = Number(match[1]) + if (!Number.isFinite(value) || value <= 0) return + return value + } + const existingTitleNumbers = new Set( - store.all.map((pty) => { - const match = pty.titleNumber - return match + store.all.flatMap((pty) => { + const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined + if (direct !== undefined) return [direct] + const parsed = parse(pty.title) + if (parsed === undefined) return [] + return [parsed] }), ) - let nextNumber = 1 - while (existingTitleNumbers.has(nextNumber)) { - nextNumber++ - } + const nextNumber = + Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find( + (number) => !existingTitleNumbers.has(number), + ) ?? 1 sdk.client.pty .create({ title: `Terminal ${nextNumber}` }) @@ -166,8 +177,8 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont } } - const load = (dir: string, id: string | undefined) => { - const key = `${dir}:${id ?? WORKSPACE_KEY}` + const load = (dir: string, session?: string) => { + const key = `${dir}:${WORKSPACE_KEY}` const existing = cache.get(key) if (existing) { cache.delete(key) @@ -176,7 +187,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont } const entry = createRoot((dispose) => ({ - value: createTerminalSession(sdk, dir, id), + value: createTerminalSession(sdk, dir, session), dispose, })) @@ -185,18 +196,18 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont return entry.value } - const session = createMemo(() => load(params.dir!, params.id)) + const workspace = createMemo(() => load(params.dir!, params.id)) return { - ready: () => session().ready(), - all: () => session().all(), - active: () => session().active(), - new: () => session().new(), - update: (pty: Partial & { id: string }) => session().update(pty), - clone: (id: string) => session().clone(id), - open: (id: string) => session().open(id), - close: (id: string) => session().close(id), - move: (id: string, to: number) => session().move(id, to), + ready: () => workspace().ready(), + all: () => workspace().all(), + active: () => workspace().active(), + new: () => workspace().new(), + update: (pty: Partial & { id: string }) => workspace().update(pty), + clone: (id: string) => workspace().clone(id), + open: (id: string) => workspace().open(id), + close: (id: string) => workspace().close(id), + move: (id: string, to: number) => workspace().move(id, to), } }, }) From 092428633fe05b33c26a94549d6e65d2235da514 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 19 Jan 2026 11:44:20 -0600 Subject: [PATCH 40/42] fix(app): layout jumping --- packages/ui/src/components/list.tsx | 38 ++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index b8a8f74607..874638c5a5 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -35,6 +35,25 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) mouseActive: false, }) + const scrollIntoView = (container: HTMLDivElement, node: HTMLElement, block: "center" | "nearest") => { + const containerRect = container.getBoundingClientRect() + const nodeRect = node.getBoundingClientRect() + const top = nodeRect.top - containerRect.top + container.scrollTop + const bottom = top + nodeRect.height + const viewTop = container.scrollTop + const viewBottom = viewTop + container.clientHeight + const target = + block === "center" + ? top - container.clientHeight / 2 + nodeRect.height / 2 + : top < viewTop + ? top + : bottom > viewBottom + ? bottom - container.clientHeight + : viewTop + const max = Math.max(0, container.scrollHeight - container.clientHeight) + container.scrollTop = Math.max(0, Math.min(target, max)) + } + const { filter, grouped, flat, active, setActive, onKeyDown, onInput } = useFilteredList(props) const searchProps = () => (typeof props.search === "object" ? props.search : {}) @@ -65,24 +84,31 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) ) createEffect(() => { - if (!scrollRef()) return + const scroll = scrollRef() + if (!scroll) return if (!props.current) return const key = props.key(props.current) requestAnimationFrame(() => { - const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(key)}"]`) - element?.scrollIntoView({ block: "center" }) + const element = scroll.querySelector(`[data-key="${CSS.escape(key)}"]`) + if (!(element instanceof HTMLElement)) return + scrollIntoView(scroll, element, "center") }) }) createEffect(() => { const all = flat() if (store.mouseActive || all.length === 0) return + const scroll = scrollRef() + if (!scroll) return if (active() === props.key(all[0])) { - scrollRef()?.scrollTo(0, 0) + scroll.scrollTo(0, 0) return } - const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(active()!)}"]`) - element?.scrollIntoView({ block: "center" }) + const key = active() + if (!key) return + const element = scroll.querySelector(`[data-key="${CSS.escape(key)}"]`) + if (!(element instanceof HTMLElement)) return + scrollIntoView(scroll, element, "center") }) createEffect(() => { From 3fd0043d1907b0e565cee73fff0f4725f7c8c0d5 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 19 Jan 2026 12:18:17 -0600 Subject: [PATCH 41/42] chore: handle fields other than reasoning_content in interleaved block --- packages/opencode/src/provider/transform.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index f6b7ec8cbc..c983bf32c4 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -123,11 +123,8 @@ export namespace ProviderTransform { return result } - if ( - model.capabilities.interleaved && - typeof model.capabilities.interleaved === "object" && - model.capabilities.interleaved.field === "reasoning_content" - ) { + if (typeof model.capabilities.interleaved === "object" && model.capabilities.interleaved.field) { + const field = model.capabilities.interleaved.field return msgs.map((msg) => { if (msg.role === "assistant" && Array.isArray(msg.content)) { const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning") @@ -136,7 +133,7 @@ export namespace ProviderTransform { // Filter out reasoning parts from content const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning") - // Include reasoning_content directly on the message for all assistant messages + // Include reasoning_content | reasoning_details directly on the message for all assistant messages if (reasoningText) { return { ...msg, @@ -145,7 +142,7 @@ export namespace ProviderTransform { ...msg.providerOptions, openaiCompatible: { ...(msg.providerOptions as any)?.openaiCompatible, - reasoning_content: reasoningText, + [field]: reasoningText, }, }, } From c2f9fd5fef5454a1e7cedab14d78267809d10fe9 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 19 Jan 2026 12:44:35 -0600 Subject: [PATCH 42/42] fix(app): reload instance after workspace reset --- packages/app/src/components/prompt-input.tsx | 11 ++++- packages/app/src/pages/layout.tsx | 49 ++++++++++++-------- 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 2f85652a93..c74edd94e6 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1056,7 +1056,16 @@ export const PromptInput: Component = (props) => { let session = info() if (!session && isNewSession) { - session = await client.session.create().then((x) => x.data ?? undefined) + session = await client.session + .create() + .then((x) => x.data ?? undefined) + .catch((err) => { + showToast({ + title: "Failed to create session", + description: errorMessage(err), + }) + return undefined + }) if (session) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) } if (!session) return diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 5312ff0a2a..81177d1386 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -965,10 +965,23 @@ export default function Layout(props: ParentProps) { if (!current) return if (directory === current.worktree) return - const reset = globalSDK.client.worktree + const progress = showToast({ + persistent: true, + title: "Resetting workspace", + description: "This may take a minute.", + }) + const dismiss = () => toaster.dismiss(progress) + + const sessions = await globalSDK.client.session + .list({ directory }) + .then((x) => x.data ?? []) + .catch(() => []) + + const result = await globalSDK.client.worktree .reset({ directory: current.worktree, worktreeResetInput: { directory } }) .then((x) => x.data) .catch((err) => { + dismiss() showToast({ title: "Failed to reset workspace", description: errorMessage(err), @@ -976,21 +989,16 @@ export default function Layout(props: ParentProps) { return false }) - const href = `/${base64Encode(directory)}/session` - navigate(href) - layout.mobileSidebar.hide() + if (!result) { + dismiss() + return + } - void (async () => { - const sessions = await globalSDK.client.session - .list({ directory }) - .then((x) => x.data ?? []) - .catch(() => []) - - if (sessions.length === 0) return - - const archivedAt = Date.now() - await Promise.all( - sessions.map((session) => + const archivedAt = Date.now() + await Promise.all( + sessions + .filter((session) => session.time.archived === undefined) + .map((session) => globalSDK.client.session .update({ sessionID: session.id, @@ -999,11 +1007,14 @@ export default function Layout(props: ParentProps) { }) .catch(() => undefined), ), - ) - })() + ) - const result = await reset - if (!result) return + await globalSDK.client.instance.dispose({ directory }).catch(() => undefined) + dismiss() + + const href = `/${base64Encode(directory)}/session` + navigate(href) + layout.mobileSidebar.hide() showToast({ title: "Workspace reset",