diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c39710bee8..9cf83ca8df 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,6 +18,54 @@ jobs: - name: Setup Bun uses: ./.github/actions/setup-bun + - name: Install Playwright browsers + working-directory: packages/app + run: bunx playwright install --with-deps + + - name: Seed opencode data + 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" + 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 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 +74,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/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": { diff --git a/nix/hashes.json b/nix/hashes.json index 5bbdf921bb..fa91b3b310 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-80+b7FwUy4mRWTzEjPrBWuR5Um67I1Rn4U/n/s/lBjs=", + "aarch64-linux": "sha256-xH/Grwh3b+HWsUkKN8LMcyMaMcmnIJYlgp38WJCat5E=", + "aarch64-darwin": "sha256-Izv6PE9gNaeYYfcqDwjTU/WYtD1y+j65annwvLzkMD8=", + "x86_64-darwin": "sha256-EG1Z0uAeyFiOeVsv0Sz1sa8/mdXuw/uvbYYrkFR3EAg=" } } 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/context.spec.ts b/packages/app/e2e/context.spec.ts new file mode 100644 index 0000000000..beabd2eb7d --- /dev/null +++ b/packages/app/e2e/context.spec.ts @@ -0,0 +1,45 @@ +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 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 contextButton = page + .locator('[data-component="button"]') + .filter({ has: page.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..fb7104b6b0 --- /dev/null +++ b/packages/app/e2e/file-open.spec.ts @@ -0,0 +1,23 @@ +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 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.locator('[data-slot="tabs-trigger"]').first()).toBeVisible() +}) 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 new file mode 100644 index 0000000000..c6fb0e3b07 --- /dev/null +++ b/packages/app/e2e/home.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from "./fixtures" +import { serverName } from "./utils" + +test("home renders and shows core entrypoints", async ({ page }) => { + await page.goto("/") + + 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 }) => { + await page.goto("/") + + const trigger = page.getByRole("button", { name: serverName }) + await expect(trigger).toBeVisible() + await trigger.click() + + const dialog = page.getByRole("dialog") + await expect(dialog).toBeVisible() + await expect(dialog.getByRole("textbox").first()).toBeVisible() +}) diff --git a/packages/app/e2e/navigation.spec.ts b/packages/app/e2e/navigation.spec.ts new file mode 100644 index 0000000000..76923af6ed --- /dev/null +++ b/packages/app/e2e/navigation.spec.ts @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000000..617c55ac16 --- /dev/null +++ b/packages/app/e2e/palette.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from "./fixtures" +import { modKey } from "./utils" + +test("search palette opens and closes", async ({ page, gotoSession }) => { + await gotoSession() + + await page.keyboard.press(`${modKey}+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/session.spec.ts b/packages/app/e2e/session.spec.ts new file mode 100644 index 0000000000..19e25a4213 --- /dev/null +++ b/packages/app/e2e/session.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from "./fixtures" +import { promptSelector } from "./utils" + +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) + + if (!created?.id) throw new Error("Session create did not return an id") + const sessionID = created.id + + try { + await gotoSession(sessionID) + + const prompt = page.locator(promptSelector) + 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) + } +}) diff --git a/packages/app/e2e/sidebar.spec.ts b/packages/app/e2e/sidebar.spec.ts new file mode 100644 index 0000000000..925590f510 --- /dev/null +++ b/packages/app/e2e/sidebar.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from "./fixtures" +import { modKey } from "./utils" + +test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => { + await gotoSession() + + const main = page.locator("main") + const closedClass = /xl:border-l/ + const isClosed = await main.evaluate((node) => node.className.includes("xl:border-l")) + + if (isClosed) { + await page.keyboard.press(`${modKey}+B`) + await expect(main).not.toHaveClass(closedClass) + } + + await page.keyboard.press(`${modKey}+B`) + await expect(main).toHaveClass(closedClass) + + await page.keyboard.press(`${modKey}+B`) + await expect(main).not.toHaveClass(closedClass) +}) diff --git a/packages/app/e2e/terminal.spec.ts b/packages/app/e2e/terminal.spec.ts new file mode 100644 index 0000000000..fc558b6325 --- /dev/null +++ b/packages/app/e2e/terminal.spec.ts @@ -0,0 +1,16 @@ +import { test, expect } from "./fixtures" +import { terminalSelector, terminalToggleKey } from "./utils" + +test("terminal panel can be toggled", async ({ page, gotoSession }) => { + await gotoSession() + + const terminal = page.locator(terminalSelector) + const initiallyOpen = await terminal.isVisible() + if (initiallyOpen) { + await page.keyboard.press(terminalToggleKey) + await expect(terminal).toHaveCount(0) + } + + 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..eb0395950a --- /dev/null +++ b/packages/app/e2e/utils.ts @@ -0,0 +1,38 @@ +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { base64Encode } from "@opencode-ai/util/encode" + +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}` : ""}` +} 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/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index 5fa1f2ceeb..c870228607 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -32,12 +32,14 @@ export function DialogEditProject(props: { project: LocalProject }) { }) const [dragOver, setDragOver] = createSignal(false) + const [iconHover, setIconHover] = createSignal(false) function handleFileSelect(file: File) { if (!isValidImageFile(file)) return const reader = new FileReader() reader.onload = (e) => { setStore("iconUrl", e.target?.result as string) + setIconHover(false) } reader.readAsDataURL(file) } @@ -84,8 +86,8 @@ export function DialogEditProject(props: { project: LocalProject }) { } return ( - -
+ +
-
+
setIconHover(true)} onMouseLeave={() => setIconHover(false)}>
document.getElementById("icon-upload")?.click()} + onClick={() => { + if (store.iconUrl && iconHover()) { + clearIcon() + } else { + document.getElementById("icon-upload")?.click() + } + }} >
- - - +
+ +
+
+ +
-
- Click or drag an image - Recommended: 128x128px +
+ Recommended size 128x128px
@@ -140,20 +177,25 @@ export function DialogEditProject(props: { project: LocalProject }) {
-
+
{(color) => ( )} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 5204a3ae81..d0c56f93ae 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/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), } }, }) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index da42becf89..0e18c20180 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -27,13 +27,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 { @@ -964,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), @@ -975,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, @@ -998,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", @@ -1296,7 +1308,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")) @@ -1330,63 +1348,100 @@ 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 hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) + 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")} - > -
- + diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index bf84171561..857b6e13ff 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -18,7 +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 { SessionMessageRail } from "@opencode-ai/ui/session-message-rail" +import { Mark } from "@opencode-ai/ui/logo" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" @@ -788,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 @@ -1096,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 + @@ -1139,41 +1139,40 @@ 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.
+
+
+
} >
- -
- -
-
{ @@ -1182,11 +1181,29 @@ export default function Page() { }} onClick={autoScroll.handleInteraction} class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar snap-y snap-mandatory" + style={{ "--session-title-height": info()?.title ? "40px" : "0px" }} > + +
+
+

{info()?.title}

+
+
+
+
1 - ? "md:pr-6 md:pl-18" - : ""), + content: "flex flex-col justify-between !overflow-visible", + container: "w-full px-4 md:px-6", }} />
@@ -1350,7 +1361,7 @@ export default function Page() {
- +
@@ -1403,26 +1414,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/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) diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index 776b422645..483db4d932 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]" @@ -296,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", }} /> @@ -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", }} >
{ + 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/format/formatter.ts b/packages/opencode/src/format/formatter.ts index 667a954c03..1a3aa1bb15 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() { @@ -337,23 +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 - }, -} - -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 + return Bun.which("rustfmt") !== null }, } 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, }, }, } 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 }), 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 ] diff --git a/packages/ui/src/components/hover-card.css b/packages/ui/src/components/hover-card.css index 43a26c98f0..f1172dfc7d 100644 --- a/packages/ui/src/components/hover-card.css +++ b/packages/ui/src/components/hover-card.css @@ -1,5 +1,7 @@ [data-slot="hover-card-trigger"] { - display: inline-flex; + display: flex; + width: 100%; + min-width: 0; } [data-component="hover-card-content"] { @@ -8,6 +10,7 @@ max-width: 320px; border-radius: var(--radius-md); background-color: var(--surface-raised-stronger-non-alpha); + pointer-events: auto; border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent); background-clip: padding-box; diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index b59497b6ff..a66e4ca5ef 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"> { diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 76e5ebb9a6..9bf395e2ae 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -36,6 +36,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 : {}) @@ -66,24 +85,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(() => { @@ -213,6 +239,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) setActive(props.key(item)) }} onMouseLeave={() => { + if (!store.mouseActive) return setActive(null) }} > diff --git a/packages/ui/src/components/message-nav.css b/packages/ui/src/components/message-nav.css index 45c3103434..01ab252954 100644 --- a/packages/ui/src/components/message-nav.css +++ b/packages/ui/src/components/message-nav.css @@ -48,6 +48,10 @@ 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08); } + + &[data-size="compact"] { + width: 24px; + } } [data-slot="message-nav-item-button"] { diff --git a/packages/ui/src/components/message-nav.tsx b/packages/ui/src/components/message-nav.tsx index 66f0942752..589d401f1a 100644 --- a/packages/ui/src/components/message-nav.tsx +++ b/packages/ui/src/components/message-nav.tsx @@ -96,8 +96,16 @@ const stopScrollAnimation = (state: ScrollAnimationState | null, containerEl?: H } } -export const MessageNav = (props: MessageNavProps) => { - const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect"]) +export function MessageNav( + props: ComponentProps<"nav"> & { + messages: UserMessage[] + current?: UserMessage + size: "normal" | "compact" + onMessageSelect: (message: UserMessage) => void + getLabel?: (message: UserMessage) => string | undefined + }, +) { + const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect", "getLabel"]) let navRef: HTMLElement | undefined let listRef: HTMLUListElement | undefined 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-message-rail.css b/packages/ui/src/components/session-message-rail.css deleted file mode 100644 index 3585258c3c..0000000000 --- a/packages/ui/src/components/session-message-rail.css +++ /dev/null @@ -1,29 +0,0 @@ -[data-slot="session-message-rail-anchor"] { - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: 40px; - height: -webkit-fill-available; - pointer-events: none; -} - -[data-slot="session-message-rail-portal"] { - z-index: 100; - margin-left: 12px; -} - -[data-component="session-message-rail"] { - display: contents; - position: relative; -} - -[data-component="session-message-rail"][data-wide] { - margin-top: 0.125rem; - left: calc(((100% - min(100%, 50rem)) / 2) - 1.5rem); - transform: translateX(-100%); -} - -[data-component="session-message-rail"]:not([data-wide]) { - 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 8ab9d3b278..0000000000 --- a/packages/ui/src/components/session-message-rail.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { UserMessage } from "@opencode-ai/sdk/v2" -import { ComponentProps, Show, splitProps, createSignal, onMount, onCleanup } from "solid-js" -import { MessageNav } from "./message-nav" -import "./session-message-rail.css" -import { Portal } from "solid-js/web" - -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"]) - let anchorRef: HTMLDivElement | undefined - const [position, setPosition] = createSignal({ top: 0, left: 0, height: 0 }) - - const updatePosition = () => { - if (anchorRef) { - const rect = anchorRef.getBoundingClientRect() - setPosition({ top: rect.top, left: rect.left, height: rect.height }) - } - } - - onMount(() => { - updatePosition() - window.addEventListener("scroll", updatePosition, true) - window.addEventListener("resize", updatePosition) - }) - - onCleanup(() => { - window.removeEventListener("scroll", updatePosition, true) - window.removeEventListener("resize", updatePosition) - }) - - return ( - 1}> -
-
(anchorRef = el)} data-slot="session-message-rail-anchor" /> - -
- -
-
-
- - ) -} diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 1e3cc0b292..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; @@ -29,23 +31,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; @@ -61,23 +46,39 @@ } } - [data-slot="session-turn-sticky-title"] { + [data-slot="session-turn-attachments"] { width: 100%; + min-width: 0; + align-self: stretch; + } + + [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: 32px; - 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"] { @@ -88,10 +89,72 @@ } [data-slot="session-turn-message-content"] { - margin-top: -18px; + margin-top: 0; 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; + gap: 6px; + padding-left: 16px; + } + [data-slot="session-turn-message-title"] { width: 100%; font-size: var(--font-size-large); @@ -276,10 +339,9 @@ } [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)); } } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index ae1321bac1..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, @@ -13,17 +14,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" @@ -33,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 @@ -79,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 @@ -119,6 +123,7 @@ function AssistantMessageItem(props: { export function SessionTurn( props: ParentProps<{ sessionID: string + sessionTitle?: string messageID: string lastUserMessageID?: string stepsExpanded?: boolean @@ -136,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 }[] = [] @@ -183,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() @@ -330,10 +349,19 @@ export function SessionTurn( const response = createMemo(() => lastTextPart()?.text) const responsePartId = createMemo(() => lastTextPart()?.id) - const hasDiffs = createMemo(() => message()?.summary?.diffs?.length) + const hasDiffs = createMemo(() => (data.store.session_diff?.[props.sessionID]?.length ?? 0) > 0) 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 @@ -364,13 +392,28 @@ 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 const [store, setStore] = createStore({ - stickyTitleRef: undefined as HTMLDivElement | undefined, - stickyTriggerRef: undefined as HTMLDivElement | undefined, - stickyHeaderHeight: 0, retrySeconds: 0, diffsOpen: [] as string[], diffLimit: diffInit, @@ -404,22 +447,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()) @@ -460,7 +487,7 @@ export function SessionTurn( }) return ( -
+
- {/* 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"} -
-
-
- {/* User Message */} -
- -
- {/* Trigger (sticky) */} - -
setStore("stickyTriggerRef", el)} data-slot="session-turn-response-trigger"> - + 0}> +
+
+
+ {/* User Message */} +
+ +
+ + {/* Trigger (sticky) */} + +
+ +
+
+
{/* Response */} 0}>
@@ -612,7 +616,7 @@ export function SessionTurn( setStore("diffsOpen", value) }} > - + {(diff) => ( @@ -658,13 +662,13 @@ export function SessionTurn( )} - store.diffLimit}> + store.diffLimit}>
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), 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 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.): 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": [] } } }