diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index 7584334a7b..65fbf0f3d6 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -6,7 +6,7 @@ runs: - name: Mount Bun Cache uses: useblacksmith/stickydisk@v1 with: - key: ${{ github.repository }}-bun-cache + key: ${{ github.repository }}-bun-cache-${{ runner.os }} path: ~/.bun - name: Setup Bun diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2a36c07e14..647b9e1886 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,8 +7,32 @@ on: pull_request: workflow_dispatch: jobs: - test: - name: test (${{ matrix.settings.name }}) + unit: + name: unit (linux) + runs-on: blacksmith-4vcpu-ubuntu-2404 + defaults: + run: + shell: bash + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Bun + uses: ./.github/actions/setup-bun + + - name: Configure git identity + run: | + git config --global user.email "bot@opencode.ai" + git config --global user.name "opencode" + + - name: Run unit tests + run: bun turbo test + + e2e: + name: e2e (${{ matrix.settings.name }}) + needs: unit strategy: fail-fast: false matrix: @@ -16,17 +40,12 @@ jobs: - name: linux host: blacksmith-4vcpu-ubuntu-2404 playwright: bunx playwright install --with-deps - workdir: . - command: | - git config --global user.email "bot@opencode.ai" - git config --global user.name "opencode" - bun turbo test - name: windows - host: windows-latest + host: blacksmith-4vcpu-windows-2025 playwright: bunx playwright install - workdir: packages/app - command: bun test:e2e:local runs-on: ${{ matrix.settings.host }} + env: + PLAYWRIGHT_BROWSERS_PATH: 0 defaults: run: shell: bash @@ -43,87 +62,10 @@ jobs: working-directory: packages/app run: ${{ matrix.settings.playwright }} - - name: Set OS-specific paths - run: | - if [ "${{ runner.os }}" = "Windows" ]; then - printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}\\opencode-e2e" >> "$GITHUB_ENV" - printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}\\opencode-e2e\\home" >> "$GITHUB_ENV" - printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}\\opencode-e2e\\share" >> "$GITHUB_ENV" - printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}\\opencode-e2e\\cache" >> "$GITHUB_ENV" - printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}\\opencode-e2e\\config" >> "$GITHUB_ENV" - printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}\\opencode-e2e\\state" >> "$GITHUB_ENV" - else - printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}/opencode-e2e" >> "$GITHUB_ENV" - printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}/opencode-e2e/home" >> "$GITHUB_ENV" - printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}/opencode-e2e/share" >> "$GITHUB_ENV" - printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}/opencode-e2e/cache" >> "$GITHUB_ENV" - printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}/opencode-e2e/config" >> "$GITHUB_ENV" - printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}/opencode-e2e/state" >> "$GITHUB_ENV" - fi - - - name: Seed opencode data - if: matrix.settings.name != 'windows' - working-directory: packages/opencode - run: bun script/seed-e2e.ts - env: - OPENCODE_DISABLE_SHARE: "true" - OPENCODE_DISABLE_LSP_DOWNLOAD: "true" - OPENCODE_DISABLE_DEFAULT_PLUGINS: "true" - OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" - OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }} - XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }} - XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }} - XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }} - XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }} - 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 - if: matrix.settings.name != 'windows' - working-directory: packages/opencode - run: bun dev -- --print-logs --log-level WARN serve --port 4096 --hostname 127.0.0.1 & - env: - OPENCODE_DISABLE_SHARE: "true" - OPENCODE_DISABLE_LSP_DOWNLOAD: "true" - OPENCODE_DISABLE_DEFAULT_PLUGINS: "true" - OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" - OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }} - XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }} - XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }} - XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }} - XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }} - OPENCODE_CLIENT: "app" - - - name: Wait for opencode server - if: matrix.settings.name != 'windows' - run: | - for i in {1..120}; do - curl -fsS "http://127.0.0.1:4096/global/health" > /dev/null && exit 0 - sleep 1 - done - exit 1 - - - name: run - working-directory: ${{ matrix.settings.workdir }} - run: ${{ matrix.settings.command }} + - name: Run app e2e tests + run: bun --cwd packages/app test:e2e:local env: CI: true - OPENCODE_DISABLE_SHARE: "true" - OPENCODE_DISABLE_LSP_DOWNLOAD: "true" - OPENCODE_DISABLE_DEFAULT_PLUGINS: "true" - OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" - OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }} - XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }} - XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }} - XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }} - XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }} - PLAYWRIGHT_SERVER_HOST: "127.0.0.1" - PLAYWRIGHT_SERVER_PORT: "4096" - VITE_OPENCODE_SERVER_HOST: "127.0.0.1" - VITE_OPENCODE_SERVER_PORT: "4096" - OPENCODE_CLIENT: "app" timeout-minutes: 30 - name: Upload Playwright artifacts @@ -136,3 +78,18 @@ jobs: path: | packages/app/e2e/test-results packages/app/e2e/playwright-report + + required: + name: test (linux) + runs-on: blacksmith-4vcpu-ubuntu-2404 + needs: + - unit + - e2e + if: always() + steps: + - name: Verify upstream test jobs passed + run: | + echo "unit=${{ needs.unit.result }}" + echo "e2e=${{ needs.e2e.result }}" + test "${{ needs.unit.result }}" = "success" + test "${{ needs.e2e.result }}" = "success" diff --git a/.prettierignore b/.prettierignore index 5f86f710fb..a2a2776596 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,2 @@ sst-env.d.ts -desktop/src/bindings.ts +packages/desktop/src/bindings.ts diff --git a/AGENTS.md b/AGENTS.md index 113a7ec5da..758714d10a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ - To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`. - ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. - The default branch in this repo is `dev`. +- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. - Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility. ## Style Guide diff --git a/bun.lock b/bun.lock index ed4171e66f..53c4879a2d 100644 --- a/bun.lock +++ b/bun.lock @@ -188,6 +188,7 @@ "@opencode-ai/ui": "workspace:*", "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", + "@solidjs/meta": "catalog:", "@tauri-apps/api": "^2", "@tauri-apps/plugin-deep-link": "~2", "@tauri-apps/plugin-dialog": "~2", @@ -286,7 +287,7 @@ "@ai-sdk/vercel": "1.0.33", "@ai-sdk/xai": "2.0.51", "@clack/prompts": "1.0.0-alpha.1", - "@gitlab/gitlab-ai-provider": "3.4.0", + "@gitlab/gitlab-ai-provider": "3.5.0", "@gitlab/opencode-gitlab-auth": "1.3.2", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", @@ -499,6 +500,9 @@ "web-tree-sitter", "tree-sitter-bash", ], + "patchedDependencies": { + "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", + }, "overrides": { "@types/bun": "catalog:", "@types/node": "catalog:", @@ -518,7 +522,7 @@ "@tailwindcss/vite": "4.1.11", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", - "@types/bun": "1.3.5", + "@types/bun": "1.3.8", "@types/luxon": "3.7.1", "@types/node": "22.13.9", "@types/semver": "7.7.1", @@ -961,7 +965,7 @@ "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], - "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.4.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-1fEZgqjSZ0WLesftw/J5UtFuJCYFDvCZCHhTH5PZAmpDEmCwllJBoe84L3+vIk38V2FGDMTW128iKTB2mVzr3A=="], + "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.5.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-OoAwCz4fOci3h/2l+PRHMclclh3IaFq8w1es2wvBJ8ca7vtglKsBYT7dvmYpsXlu7pg9mopbjcexvmVCQEUTAQ=="], "@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.2", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-pvGrC+aDVLY8bRCC/fZaG/Qihvt2r4by5xbTo5JTSz9O7yIcR6xG2d9Wkuu4bcXFz674z2C+i5bUk+J/RSdBpg=="], @@ -1843,7 +1847,7 @@ "@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="], - "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -2159,7 +2163,7 @@ "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], - "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], "bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="], diff --git a/nix/hashes.json b/nix/hashes.json index 751442d756..eb1578dcde 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-ufEpxjmlJeft9tI+WxxO+Zbh1pdAaLOURCDBpoQqR0w=", - "aarch64-linux": "sha256-z3K6W5oYZNUdV0rjoAZjvNQcifM5bXamLIrD+ZvJ4kA=", - "aarch64-darwin": "sha256-+QikplmNhxGF2Nd4L1BG/xyl+24GVhDYMTtK6xCKy/s=", - "x86_64-darwin": "sha256-hAcrCT2X02ymwgj/0BAmD2gF66ylGYzbfcqPta/LVEU=" + "x86_64-linux": "sha256-UBz5qXhO+Xy6XptVdbo9V0wKsvZgItmHkWDm6I5VRCk=", + "aarch64-linux": "sha256-G2ezu/ThZR3kYfHnbD0EOcLoAa6hwtICpmo9r+bqibE=", + "aarch64-darwin": "sha256-PhSE23OzNlyfNFP5LffA3AtyN+hsyCeGInmDBBRjr0g=", + "x86_64-darwin": "sha256-vWusYJD+7ClDLUFy1wEqRLf9hY8V43iqdqnZ6YWkh1Q=" } } diff --git a/package.json b/package.json index 7846ce6ea6..9552e27c08 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.5", + "packageManager": "bun@1.3.8", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev:desktop": "bun --cwd packages/desktop tauri dev", @@ -23,7 +23,7 @@ "packages/slack" ], "catalog": { - "@types/bun": "1.3.5", + "@types/bun": "1.3.8", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", "ulid": "3.0.1", @@ -102,5 +102,7 @@ "@types/bun": "catalog:", "@types/node": "catalog:" }, - "patchedDependencies": {} + "patchedDependencies": { + "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch" + } } diff --git a/packages/app/package.json b/packages/app/package.json index abef97e81f..a995880e01 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -5,7 +5,8 @@ "type": "module", "exports": { ".": "./src/index.ts", - "./vite": "./vite.js" + "./vite": "./vite.js", + "./index.css": "./src/index.css" }, "scripts": { "typecheck": "tsgo -b", @@ -13,7 +14,9 @@ "dev": "vite", "build": "vite build", "serve": "vite preview", - "test": "playwright test", + "test": "bun run test:unit", + "test:unit": "bun test --preload ./happydom.ts ./src", + "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", "test:e2e": "playwright test", "test:e2e:local": "bun script/e2e-local.ts", "test:e2e:ui": "playwright test --ui", diff --git a/packages/app/src/addons/serialize.test.ts b/packages/app/src/addons/serialize.test.ts index 7fb1a61f35..7f6780557d 100644 --- a/packages/app/src/addons/serialize.test.ts +++ b/packages/app/src/addons/serialize.test.ts @@ -36,7 +36,7 @@ function writeAndWait(term: Terminal, data: string): Promise { }) } -describe.skip("SerializeAddon", () => { +describe("SerializeAddon", () => { describe("ANSI color preservation", () => { test("should preserve text attributes (bold, italic, underline)", async () => { const { term, addon } = createTerminal() diff --git a/packages/app/src/addons/serialize.ts b/packages/app/src/addons/serialize.ts index 3f0a8fb0aa..4cab55b3f2 100644 --- a/packages/app/src/addons/serialize.ts +++ b/packages/app/src/addons/serialize.ts @@ -56,6 +56,39 @@ interface IBufferCell { isDim(): boolean } +type TerminalBuffers = { + active?: IBuffer + normal?: IBuffer + alternate?: IBuffer +} + +const isRecord = (value: unknown): value is Record => { + return typeof value === "object" && value !== null +} + +const isBuffer = (value: unknown): value is IBuffer => { + if (!isRecord(value)) return false + if (typeof value.length !== "number") return false + if (typeof value.cursorX !== "number") return false + if (typeof value.cursorY !== "number") return false + if (typeof value.baseY !== "number") return false + if (typeof value.viewportY !== "number") return false + if (typeof value.getLine !== "function") return false + if (typeof value.getNullCell !== "function") return false + return true +} + +const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined => { + if (!isRecord(value)) return + const raw = value.buffer + if (!isRecord(raw)) return + const active = isBuffer(raw.active) ? raw.active : undefined + const normal = isBuffer(raw.normal) ? raw.normal : undefined + const alternate = isBuffer(raw.alternate) ? raw.alternate : undefined + if (!active && !normal) return + return { active, normal, alternate } +} + // ============================================================================ // Types // ============================================================================ @@ -498,14 +531,13 @@ export class SerializeAddon implements ITerminalAddon { throw new Error("Cannot use addon until it has been loaded") } - const terminal = this._terminal as any - const buffer = terminal.buffer + const buffer = getTerminalBuffers(this._terminal) if (!buffer) { return "" } - const normalBuffer = buffer.normal || buffer.active + const normalBuffer = buffer.normal ?? buffer.active const altBuffer = buffer.alternate if (!normalBuffer) { @@ -533,14 +565,13 @@ export class SerializeAddon implements ITerminalAddon { throw new Error("Cannot use addon until it has been loaded") } - const terminal = this._terminal as any - const buffer = terminal.buffer + const buffer = getTerminalBuffers(this._terminal) if (!buffer) { return "" } - const activeBuffer = buffer.active || buffer.normal + const activeBuffer = buffer.active ?? buffer.normal if (!activeBuffer) { return "" } diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 11fdb57432..8a111472ba 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -30,7 +30,7 @@ import { HighlightsProvider } from "@/context/highlights" import Layout from "@/pages/layout" import DirectoryLayout from "@/pages/directory-layout" import { ErrorPage } from "./pages/error" -import { Suspense } from "solid-js" +import { Suspense, JSX } from "solid-js" const Home = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) @@ -84,7 +84,7 @@ function ServerKey(props: ParentProps) { ) } -export function AppInterface(props: { defaultUrl?: string }) { +export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element }) { const platform = usePlatform() const stored = (() => { @@ -111,7 +111,7 @@ export function AppInterface(props: { defaultUrl?: string }) { ( + root={(routerProps) => ( @@ -119,7 +119,10 @@ export function AppInterface(props: { defaultUrl?: string }) { - {props.children} + + {props.children} + {routerProps.children} + diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 28a947f3b3..53773ed9ea 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -124,16 +124,16 @@ export function DialogCustomProvider(props: Props) { const key = apiKey && !env ? apiKey : undefined const idError = !providerID - ? "Provider ID is required" + ? language.t("provider.custom.error.providerID.required") : !PROVIDER_ID.test(providerID) - ? "Use lowercase letters, numbers, hyphens, or underscores" + ? language.t("provider.custom.error.providerID.format") : undefined - const nameError = !name ? "Display name is required" : undefined + const nameError = !name ? language.t("provider.custom.error.name.required") : undefined const urlError = !baseURL - ? "Base URL is required" + ? language.t("provider.custom.error.baseURL.required") : !/^https?:\/\//.test(baseURL) - ? "Must start with http:// or https://" + ? language.t("provider.custom.error.baseURL.format") : undefined const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID) @@ -141,21 +141,21 @@ export function DialogCustomProvider(props: Props) { const existsError = idError ? undefined : existingProvider && !disabled - ? "That provider ID already exists" + ? language.t("provider.custom.error.providerID.exists") : undefined const seenModels = new Set() const modelErrors = form.models.map((m) => { const id = m.id.trim() const modelIdError = !id - ? "Required" + ? language.t("provider.custom.error.required") : seenModels.has(id) - ? "Duplicate" + ? language.t("provider.custom.error.duplicate") : (() => { seenModels.add(id) return undefined })() - const modelNameError = !m.name.trim() ? "Required" : undefined + const modelNameError = !m.name.trim() ? language.t("provider.custom.error.required") : undefined return { id: modelIdError, name: modelNameError } }) const modelsValid = modelErrors.every((m) => !m.id && !m.name) @@ -168,14 +168,14 @@ export function DialogCustomProvider(props: Props) { if (!key && !value) return {} const keyError = !key - ? "Required" + ? language.t("provider.custom.error.required") : seenHeaders.has(key.toLowerCase()) - ? "Duplicate" + ? language.t("provider.custom.error.duplicate") : (() => { seenHeaders.add(key.toLowerCase()) return undefined })() - const valueError = !value ? "Required" : undefined + const valueError = !value ? language.t("provider.custom.error.required") : undefined return { key: keyError, value: valueError } }) const headersValid = headerErrors.every((h) => !h.key && !h.value) @@ -278,64 +278,64 @@ export function DialogCustomProvider(props: Props) {
-
Custom provider
+
{language.t("provider.custom.title")}

- Configure an OpenAI-compatible provider. See the{" "} + {language.t("provider.custom.description.prefix")} - provider config docs + {language.t("provider.custom.description.link")} - . + {language.t("provider.custom.description.suffix")}

- + {(m, i) => (
setForm("models", i(), "id", v)} validationState={errors.models[i()]?.id ? "invalid" : undefined} @@ -344,9 +344,9 @@ export function DialogCustomProvider(props: Props) {
setForm("models", i(), "name", v)} validationState={errors.models[i()]?.name ? "invalid" : undefined} @@ -360,26 +360,26 @@ export function DialogCustomProvider(props: Props) { class="mt-1.5" onClick={() => removeModel(i())} disabled={form.models.length <= 1} - aria-label="Remove model" + aria-label={language.t("provider.custom.models.remove")} />
)}
- + {(h, i) => (
setForm("headers", i(), "key", v)} validationState={errors.headers[i()]?.key ? "invalid" : undefined} @@ -388,9 +388,9 @@ export function DialogCustomProvider(props: Props) {
setForm("headers", i(), "value", v)} validationState={errors.headers[i()]?.value ? "invalid" : undefined} @@ -404,18 +404,18 @@ export function DialogCustomProvider(props: Props) { class="mt-1.5" onClick={() => removeHeader(i())} disabled={form.headers.length <= 1} - aria-label="Remove header" + aria-label={language.t("provider.custom.headers.remove")} />
)}
diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index 622daee7a3..dbad81798f 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -223,7 +223,7 @@ export function DialogEditProject(props: { project: LocalProject }) { value={store.startup} onChange={(v) => setStore("startup", v)} spellcheck={false} - class="max-h-40 w-full font-mono text-xs no-scrollbar" + class="max-h-14 w-full overflow-y-auto font-mono text-xs" />
diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 36448dd3e6..8e221577b9 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -15,6 +15,7 @@ import { useLayout } from "@/context/layout" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { decode64 } from "@/utils/base64" +import { getRelativeTime } from "@/utils/time" type EntryType = "command" | "file" | "session" @@ -30,6 +31,7 @@ type Entry = { directory?: string sessionID?: string archived?: number + updated?: number } type DialogSelectFileMode = "all" | "files" @@ -120,6 +122,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil title: string description: string archived?: number + updated?: number }): Entry => ({ id: `session:${input.directory}:${input.id}`, type: "session", @@ -129,6 +132,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil directory: input.directory, sessionID: input.id, archived: input.archived, + updated: input.updated, }) const list = createMemo(() => allowed().map(commandItem)) @@ -214,6 +218,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil description, directory, archived: s.time?.archived, + updated: s.time?.updated, })), ) .catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[]) @@ -384,6 +389,11 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
+ + + {getRelativeTime(new Date(item.updated!).toISOString())} + + diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 3d0d6c7938..26021f06aa 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -87,11 +87,13 @@ const ModelList: Component<{ ) } -export function ModelSelectorPopover(props: { +type ModelSelectorTriggerProps = Omit, "as" | "ref"> + +export function ModelSelectorPopover(props: { provider?: string children?: JSX.Element - triggerAs?: T - triggerProps?: ComponentProps + triggerAs?: ValidComponent + triggerProps?: ModelSelectorTriggerProps }) { const [store, setStore] = createStore<{ open: boolean @@ -176,11 +178,7 @@ export function ModelSelectorPopover(props: { placement="top-start" gutter={8} > - setStore("trigger", el)} - as={props.triggerAs ?? "div"} - {...(props.triggerProps as any)} - > + setStore("trigger", el)} as={props.triggerAs ?? "div"} {...props.triggerProps}> {props.children} diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index e9e7646d5a..65b679f70a 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -1,4 +1,4 @@ -import { createResource, createEffect, createMemo, onCleanup, Show, createSignal } from "solid-js" +import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -6,17 +6,15 @@ import { List } from "@opencode-ai/ui/list" import { Button } from "@opencode-ai/ui/button" import { IconButton } from "@opencode-ai/ui/icon-button" import { TextField } from "@opencode-ai/ui/text-field" -import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server" +import { normalizeServerUrl, useServer } from "@/context/server" import { usePlatform } from "@/context/platform" -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { useNavigate } from "@solidjs/router" import { useLanguage } from "@/context/language" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Tooltip } from "@opencode-ai/ui/tooltip" import { useGlobalSDK } from "@/context/global-sdk" import { showToast } from "@opencode-ai/ui/toast" - -type ServerStatus = { healthy: boolean; version?: string } +import { ServerRow } from "@/components/server/server-row" +import { checkServerHealth, type ServerHealth } from "@/utils/server-health" interface AddRowProps { value: string @@ -40,19 +38,6 @@ interface EditRowProps { onBlur: () => void } -async function checkHealth(url: string, platform: ReturnType): Promise { - const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000) - const sdk = createOpencodeClient({ - baseUrl: url, - fetch: platform.fetch, - signal, - }) - return sdk.global - .health() - .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version })) - .catch(() => ({ healthy: false })) -} - function AddRow(props: AddRowProps) { return (
@@ -131,7 +116,7 @@ export function DialogSelectServer() { const globalSDK = useGlobalSDK() const language = useLanguage() const [store, setStore] = createStore({ - status: {} as Record, + status: {} as Record, addServer: { url: "", adding: false, @@ -165,6 +150,7 @@ export function DialogSelectServer() { { initialValue: null }, ) const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl) + const fetcher = platform.fetch ?? globalThis.fetch const looksComplete = (value: string) => { const normalized = normalizeServerUrl(value) @@ -180,7 +166,7 @@ export function DialogSelectServer() { if (!looksComplete(value)) return const normalized = normalizeServerUrl(value) if (!normalized) return - const result = await checkHealth(normalized, platform) + const result = await checkServerHealth(normalized, fetcher) setStatus(result.healthy) } @@ -227,7 +213,7 @@ export function DialogSelectServer() { if (!list.length) return list const active = current() const order = new Map(list.map((url, index) => [url, index] as const)) - const rank = (value?: ServerStatus) => { + const rank = (value?: ServerHealth) => { if (value?.healthy === true) return 0 if (value?.healthy === false) return 2 return 1 @@ -242,10 +228,10 @@ export function DialogSelectServer() { }) async function refreshHealth() { - const results: Record = {} + const results: Record = {} await Promise.all( items().map(async (url) => { - results[url] = await checkHealth(url, platform) + results[url] = await checkServerHealth(url, fetcher) }), ) setStore("status", reconcile(results)) @@ -300,7 +286,7 @@ export function DialogSelectServer() { setStore("addServer", { adding: true, error: "" }) - const result = await checkHealth(normalized, platform) + const result = await checkServerHealth(normalized, fetcher) setStore("addServer", { adding: false }) if (!result.healthy) { @@ -327,7 +313,7 @@ export function DialogSelectServer() { setStore("editServer", { busy: true, error: "" }) - const result = await checkHealth(normalized, platform) + const result = await checkServerHealth(normalized, fetcher) setStore("editServer", { busy: false }) if (!result.healthy) { @@ -369,6 +355,9 @@ export function DialogSelectServer() { async function handleRemove(url: string) { server.remove(url) + if ((await platform.getDefaultServerUrl?.()) === url) { + platform.setDefaultServerUrl?.(null) + } } return ( @@ -410,35 +399,6 @@ export function DialogSelectServer() { } > {(i) => { - const [truncated, setTruncated] = createSignal(false) - let nameRef: HTMLSpanElement | undefined - let versionRef: HTMLSpanElement | undefined - - const check = () => { - const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false - const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false - setTruncated(nameTruncated || versionTruncated) - } - - createEffect(() => { - check() - window.addEventListener("resize", check) - onCleanup(() => window.removeEventListener("resize", check)) - }) - - const tooltipValue = () => { - const name = serverDisplayName(i) - const version = store.status[i]?.version - return ( - - {name} - - {version} - - - ) - } - return (
} > - -
-
- - {serverDisplayName(i)} - - - - {store.status[i]?.version} - - + {language.t("dialog.server.status.default")} -
- + } + />
diff --git a/packages/app/src/components/file-tree.test.ts b/packages/app/src/components/file-tree.test.ts new file mode 100644 index 0000000000..eb048e29ed --- /dev/null +++ b/packages/app/src/components/file-tree.test.ts @@ -0,0 +1,77 @@ +import { beforeAll, describe, expect, mock, test } from "bun:test" + +let shouldListRoot: typeof import("./file-tree").shouldListRoot +let shouldListExpanded: typeof import("./file-tree").shouldListExpanded +let dirsToExpand: typeof import("./file-tree").dirsToExpand + +beforeAll(async () => { + mock.module("@solidjs/router", () => ({ + useParams: () => ({}), + })) + mock.module("@/context/file", () => ({ + useFile: () => ({ + tree: { + state: () => undefined, + list: () => Promise.resolve(), + children: () => [], + expand: () => {}, + collapse: () => {}, + }, + }), + })) + mock.module("@opencode-ai/ui/collapsible", () => ({ + Collapsible: { + Trigger: (props: { children?: unknown }) => props.children, + Content: (props: { children?: unknown }) => props.children, + }, + })) + mock.module("@opencode-ai/ui/file-icon", () => ({ FileIcon: () => null })) + mock.module("@opencode-ai/ui/icon", () => ({ Icon: () => null })) + mock.module("@opencode-ai/ui/tooltip", () => ({ Tooltip: (props: { children?: unknown }) => props.children })) + const mod = await import("./file-tree") + shouldListRoot = mod.shouldListRoot + shouldListExpanded = mod.shouldListExpanded + dirsToExpand = mod.dirsToExpand +}) + +describe("file tree fetch discipline", () => { + test("root lists on mount unless already loaded or loading", () => { + expect(shouldListRoot({ level: 0 })).toBe(true) + expect(shouldListRoot({ level: 0, dir: { loaded: true } })).toBe(false) + expect(shouldListRoot({ level: 0, dir: { loading: true } })).toBe(false) + expect(shouldListRoot({ level: 1 })).toBe(false) + }) + + test("nested dirs list only when expanded and stale", () => { + expect(shouldListExpanded({ level: 1 })).toBe(false) + expect(shouldListExpanded({ level: 1, dir: { expanded: false } })).toBe(false) + expect(shouldListExpanded({ level: 1, dir: { expanded: true } })).toBe(true) + expect(shouldListExpanded({ level: 1, dir: { expanded: true, loaded: true } })).toBe(false) + expect(shouldListExpanded({ level: 1, dir: { expanded: true, loading: true } })).toBe(false) + expect(shouldListExpanded({ level: 0, dir: { expanded: true } })).toBe(false) + }) + + test("allowed auto-expand picks only collapsed dirs", () => { + const expanded = new Set() + const filter = { dirs: new Set(["src", "src/components"]) } + + const first = dirsToExpand({ + level: 0, + filter, + expanded: (dir) => expanded.has(dir), + }) + + expect(first).toEqual(["src", "src/components"]) + + for (const dir of first) expanded.add(dir) + + const second = dirsToExpand({ + level: 0, + filter, + expanded: (dir) => expanded.has(dir), + }) + + expect(second).toEqual([]) + expect(dirsToExpand({ level: 1, filter, expanded: () => false })).toEqual([]) + }) +}) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 491a16de77..4a3e276724 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -8,6 +8,7 @@ import { createMemo, For, Match, + on, Show, splitProps, Switch, @@ -18,6 +19,14 @@ import { import { Dynamic } from "solid-js/web" import type { FileNode } from "@opencode-ai/sdk/v2" +function pathToFileUrl(filepath: string): string { + const encodedPath = filepath + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/") + return `file://${encodedPath}` +} + type Kind = "add" | "del" | "mix" type Filter = { @@ -25,6 +34,34 @@ type Filter = { dirs: Set } +export function shouldListRoot(input: { level: number; dir?: { loaded?: boolean; loading?: boolean } }) { + if (input.level !== 0) return false + if (input.dir?.loaded) return false + if (input.dir?.loading) return false + return true +} + +export function shouldListExpanded(input: { + level: number + dir?: { expanded?: boolean; loaded?: boolean; loading?: boolean } +}) { + if (input.level === 0) return false + if (!input.dir?.expanded) return false + if (input.dir.loaded) return false + if (input.dir.loading) return false + return true +} + +export function dirsToExpand(input: { + level: number + filter?: { dirs: Set } + expanded: (dir: string) => boolean +}) { + if (input.level !== 0) return [] + if (!input.filter) return [] + return [...input.filter.dirs].filter((dir) => !input.expanded(dir)) +} + export default function FileTree(props: { path: string class?: string @@ -111,19 +148,30 @@ export default function FileTree(props: { createEffect(() => { const current = filter() - if (!current) return - if (level !== 0) return - - for (const dir of current.dirs) { - const expanded = untrack(() => file.tree.state(dir)?.expanded) ?? false - if (expanded) continue - file.tree.expand(dir) - } + const dirs = dirsToExpand({ + level, + filter: current, + expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false, + }) + for (const dir of dirs) file.tree.expand(dir) }) + createEffect( + on( + () => props.path, + (path) => { + const dir = untrack(() => file.tree.state(path)) + if (!shouldListRoot({ level, dir })) return + void file.tree.list(path) + }, + { defer: false }, + ), + ) + createEffect(() => { - const path = props.path - untrack(() => void file.tree.list(path)) + const dir = file.tree.state(props.path) + if (!shouldListExpanded({ level, dir })) return + void file.tree.list(props.path) }) const nodes = createMemo(() => { @@ -207,7 +255,7 @@ export default function FileTree(props: { onDragStart={(e: DragEvent) => { if (!draggable()) return e.dataTransfer?.setData("text/plain", `file:${local.node.path}`) - e.dataTransfer?.setData("text/uri-list", `file://${local.node.path}`) + e.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path)) if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy" const dragImage = document.createElement("div") diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 9186dcfa3e..2bccddc291 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1,21 +1,9 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" -import { - createEffect, - on, - Component, - Show, - For, - onMount, - onCleanup, - Switch, - Match, - createMemo, - createSignal, -} from "solid-js" -import { createStore, produce } from "solid-js/store" +import { createEffect, on, Component, Show, For, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js" +import { createStore } from "solid-js/store" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" -import { useFile, type FileSelection } from "@/context/file" +import { useFile } from "@/context/file" import { ContentPart, DEFAULT_PROMPT, @@ -28,10 +16,9 @@ import { } from "@/context/prompt" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" -import { useNavigate, useParams } from "@solidjs/router" +import { useParams } from "@solidjs/router" import { useSync } from "@/context/sync" import { useComments } from "@/context/comments" -import { FileIcon } from "@opencode-ai/ui/file-icon" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" @@ -39,35 +26,25 @@ import type { IconName } from "@opencode-ai/ui/icons/provider" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" -import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ModelSelectorPopover } from "@/components/dialog-select-model" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { useProviders } from "@/hooks/use-providers" import { useCommand } from "@/context/command" import { Persist, persisted } from "@/utils/persist" -import { Identifier } from "@/utils/id" -import { Worktree as WorktreeState } from "@/utils/worktree" import { SessionContextUsage } from "@/components/session-context-usage" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" -import { useGlobalSync } from "@/context/global-sync" -import { usePlatform } from "@/context/platform" -import { createOpencodeClient, type Message, type Part } from "@opencode-ai/sdk/v2/client" -import { Binary } from "@opencode-ai/util/binary" -import { showToast } from "@opencode-ai/ui/toast" -import { base64Encode } from "@opencode-ai/util/encode" - -const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] -const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] - -type PendingPrompt = { - abort: AbortController - cleanup: VoidFunction -} - -const pending = new Map() +import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" +import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments" +import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history" +import { createPromptSubmit } from "./prompt-input/submit" +import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover" +import { PromptContextItems } from "./prompt-input/context-items" +import { PromptImageAttachments } from "./prompt-input/image-attachments" +import { PromptDragOverlay } from "./prompt-input/drag-overlay" +import { promptPlaceholder } from "./prompt-input/placeholder" +import { ImagePreview } from "@opencode-ai/ui/image-preview" interface PromptInputProps { class?: string @@ -105,22 +82,9 @@ const EXAMPLES = [ "prompt.example.25", ] as const -interface SlashCommand { - id: string - trigger: string - title: string - description?: string - keybind?: string - type: "builtin" | "custom" - source?: "command" | "mcp" | "skill" -} - export const PromptInput: Component = (props) => { - const navigate = useNavigate() const sdk = useSDK() const sync = useSync() - const globalSync = useGlobalSync() - const platform = usePlatform() const local = useLocal() const files = useFile() const prompt = usePrompt() @@ -232,8 +196,8 @@ export const PromptInput: Component = (props) => { }, ) const working = createMemo(() => status()?.type !== "idle") - const imageAttachments = createMemo( - () => prompt.current().filter((part) => part.type === "image") as ImageAttachmentPart[], + const imageAttachments = createMemo(() => + prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), ) const [store, setStore] = createStore<{ @@ -253,6 +217,14 @@ export const PromptInput: Component = (props) => { mode: "normal", applyingHistory: false, }) + const placeholder = createMemo(() => + promptPlaceholder({ + mode: store.mode, + commentCount: commentCount(), + example: language.t(EXAMPLES[store.placeholder]), + t: (key, params) => language.t(key as Parameters[0], params as never), + }), + ) const MAX_HISTORY = 100 const [history, setHistory] = persisted( @@ -272,20 +244,6 @@ export const PromptInput: Component = (props) => { }), ) - const clonePromptParts = (prompt: Prompt): Prompt => - prompt.map((part) => { - if (part.type === "text") return { ...part } - if (part.type === "image") return { ...part } - if (part.type === "agent") return { ...part } - return { - ...part, - selection: part.selection ? { ...part.selection } : undefined, - } - }) - - const promptLength = (prompt: Prompt) => - prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0) - const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { const length = position === "start" ? 0 : promptLength(p) setStore("applyingHistory", true) @@ -329,110 +287,6 @@ export const PromptInput: Component = (props) => { const [composing, setComposing] = createSignal(false) const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 - const addImageAttachment = async (file: File) => { - if (!ACCEPTED_FILE_TYPES.includes(file.type)) return - - const reader = new FileReader() - reader.onload = () => { - const dataUrl = reader.result as string - const attachment: ImageAttachmentPart = { - type: "image", - id: crypto.randomUUID(), - filename: file.name, - mime: file.type, - dataUrl, - } - const cursorPosition = prompt.cursor() ?? getCursorPosition(editorRef) - prompt.set([...prompt.current(), attachment], cursorPosition) - } - reader.readAsDataURL(file) - } - - const removeImageAttachment = (id: string) => { - const current = prompt.current() - const next = current.filter((part) => part.type !== "image" || part.id !== id) - prompt.set(next, prompt.cursor()) - } - - const handlePaste = async (event: ClipboardEvent) => { - if (!isFocused()) return - const clipboardData = event.clipboardData - if (!clipboardData) return - - event.preventDefault() - event.stopPropagation() - - const items = Array.from(clipboardData.items) - const fileItems = items.filter((item) => item.kind === "file") - const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type)) - - if (imageItems.length > 0) { - for (const item of imageItems) { - const file = item.getAsFile() - if (file) await addImageAttachment(file) - } - return - } - - if (fileItems.length > 0) { - showToast({ - title: language.t("prompt.toast.pasteUnsupported.title"), - description: language.t("prompt.toast.pasteUnsupported.description"), - }) - return - } - - const plainText = clipboardData.getData("text/plain") ?? "" - if (!plainText) return - addPart({ type: "text", content: plainText, start: 0, end: 0 }) - } - - const handleGlobalDragOver = (event: DragEvent) => { - if (dialog.active) return - - event.preventDefault() - const hasFiles = event.dataTransfer?.types.includes("Files") - if (hasFiles) { - setStore("dragging", true) - } - } - - const handleGlobalDragLeave = (event: DragEvent) => { - if (dialog.active) return - - // relatedTarget is null when leaving the document window - if (!event.relatedTarget) { - setStore("dragging", false) - } - } - - const handleGlobalDrop = async (event: DragEvent) => { - if (dialog.active) return - - event.preventDefault() - setStore("dragging", false) - - const dropped = event.dataTransfer?.files - if (!dropped) return - - for (const file of Array.from(dropped)) { - if (ACCEPTED_FILE_TYPES.includes(file.type)) { - await addImageAttachment(file) - } - } - } - - onMount(() => { - document.addEventListener("dragover", handleGlobalDragOver) - document.addEventListener("dragleave", handleGlobalDragLeave) - document.addEventListener("drop", handleGlobalDrop) - }) - onCleanup(() => { - document.removeEventListener("dragover", handleGlobalDragOver) - document.removeEventListener("dragleave", handleGlobalDragLeave) - document.removeEventListener("drop", handleGlobalDrop) - }) - createEffect(() => { if (!isFocused()) setStore("popover", null) }) @@ -443,10 +297,6 @@ export const PromptInput: Component = (props) => { if (!isFocused()) setComposing(false) }) - type AtOption = - | { type: "agent"; name: string; display: string } - | { type: "file"; path: string; display: string; recent?: boolean } - const agentList = createMemo(() => sync.data.agent .filter((agent) => !agent.hidden && agent.mode !== "primary") @@ -656,7 +506,7 @@ export const PromptInput: Component = (props) => { on( () => prompt.current(), (currentParts) => { - const inputParts = currentParts.filter((part) => part.type !== "image") as Prompt + const inputParts = currentParts.filter((part) => part.type !== "image") if (mirror.input) { mirror.input = false @@ -826,36 +676,6 @@ export const PromptInput: Component = (props) => { queueScroll() } - const setRangeEdge = (range: Range, edge: "start" | "end", offset: number) => { - let remaining = offset - const nodes = Array.from(editorRef.childNodes) - - for (const node of nodes) { - const length = getNodeLength(node) - const isText = node.nodeType === Node.TEXT_NODE - const isPill = - node.nodeType === Node.ELEMENT_NODE && - ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent") - const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" - - if (isText && remaining <= length) { - if (edge === "start") range.setStart(node, remaining) - if (edge === "end") range.setEnd(node, remaining) - return - } - - if ((isPill || isBreak) && remaining <= length) { - if (edge === "start" && remaining === 0) range.setStartBefore(node) - if (edge === "start" && remaining > 0) range.setStartAfter(node) - if (edge === "end" && remaining === 0) range.setEndBefore(node) - if (edge === "end" && remaining > 0) range.setEndAfter(node) - return - } - - remaining -= length - } - } - const addPart = (part: ContentPart) => { const selection = window.getSelection() if (!selection || selection.rangeCount === 0) return @@ -873,8 +693,8 @@ export const PromptInput: Component = (props) => { if (atMatch) { const start = atMatch.index ?? cursorPosition - atMatch[0].length - setRangeEdge(range, "start", start) - setRangeEdge(range, "end", cursorPosition) + setRangeEdge(editorRef, range, "start", start) + setRangeEdge(editorRef, range, "end", cursorPosition) } range.deleteContents() @@ -913,82 +733,58 @@ export const PromptInput: Component = (props) => { setStore("popover", null) } - const abort = async () => { - const sessionID = params.id - if (!sessionID) return Promise.resolve() - const queued = pending.get(sessionID) - if (queued) { - queued.abort.abort() - queued.cleanup() - pending.delete(sessionID) - return Promise.resolve() - } - return sdk.client.session - .abort({ - sessionID, - }) - .catch(() => {}) - } - const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { - const text = prompt - .map((p) => ("content" in p ? p.content : "")) - .join("") - .trim() - const hasImages = prompt.some((part) => part.type === "image") - if (!text && !hasImages) return - - const entry = clonePromptParts(prompt) const currentHistory = mode === "shell" ? shellHistory : history const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory - const lastEntry = currentHistory.entries[0] - if (lastEntry && isPromptEqual(lastEntry, entry)) return - - setCurrentHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY)) + const next = prependHistoryEntry(currentHistory.entries, prompt) + if (next === currentHistory.entries) return + setCurrentHistory("entries", next) } const navigateHistory = (direction: "up" | "down") => { - const entries = store.mode === "shell" ? shellHistory.entries : history.entries - const current = store.historyIndex - - if (direction === "up") { - if (entries.length === 0) return false - if (current === -1) { - setStore("savedPrompt", clonePromptParts(prompt.current())) - setStore("historyIndex", 0) - applyHistoryPrompt(entries[0], "start") - return true - } - if (current < entries.length - 1) { - const next = current + 1 - setStore("historyIndex", next) - applyHistoryPrompt(entries[next], "start") - return true - } - return false - } - - if (current > 0) { - const next = current - 1 - setStore("historyIndex", next) - applyHistoryPrompt(entries[next], "end") - return true - } - if (current === 0) { - setStore("historyIndex", -1) - const saved = store.savedPrompt - if (saved) { - applyHistoryPrompt(saved, "end") - setStore("savedPrompt", null) - return true - } - applyHistoryPrompt(DEFAULT_PROMPT, "end") - return true - } - - return false + const result = navigatePromptHistory({ + direction, + entries: store.mode === "shell" ? shellHistory.entries : history.entries, + historyIndex: store.historyIndex, + currentPrompt: prompt.current(), + savedPrompt: store.savedPrompt, + }) + if (!result.handled) return false + setStore("historyIndex", result.historyIndex) + setStore("savedPrompt", result.savedPrompt) + applyHistoryPrompt(result.prompt, result.cursor) + return true } + const { addImageAttachment, removeImageAttachment, handlePaste } = createPromptAttachments({ + editor: () => editorRef, + isFocused, + isDialogActive: () => !!dialog.active, + setDragging: (value) => setStore("dragging", value), + addPart, + }) + + const { abort, handleSubmit } = createPromptSubmit({ + info, + imageAttachments, + commentCount, + mode: () => store.mode, + working, + editor: () => editorRef, + queueScroll, + promptLength, + addToHistory, + resetHistoryNavigation: () => { + setStore("historyIndex", -1) + setStore("savedPrompt", null) + }, + setMode: (mode) => setStore("mode", mode), + setPopover: (popover) => setStore("popover", popover), + newSessionWorktree: props.newSessionWorktree, + onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, + onSubmit: props.onSubmit, + }) + const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Backspace") { const selection = window.getSelection() @@ -1127,609 +923,23 @@ export const PromptInput: Component = (props) => { } } - const handleSubmit = async (event: Event) => { - event.preventDefault() - - const currentPrompt = prompt.current() - const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") - const images = imageAttachments().slice() - const mode = store.mode - - if (text.trim().length === 0 && images.length === 0 && commentCount() === 0) { - if (working()) abort() - return - } - - const currentModel = local.model.current() - const currentAgent = local.agent.current() - if (!currentModel || !currentAgent) { - showToast({ - title: language.t("prompt.toast.modelAgentRequired.title"), - description: language.t("prompt.toast.modelAgentRequired.description"), - }) - return - } - - const errorMessage = (err: unknown) => { - if (err && typeof err === "object" && "data" in err) { - const data = (err as { data?: { message?: string } }).data - if (data?.message) return data.message - } - if (err instanceof Error) return err.message - return language.t("common.requestFailed") - } - - addToHistory(currentPrompt, mode) - setStore("historyIndex", -1) - setStore("savedPrompt", null) - - const projectDirectory = sdk.directory - const isNewSession = !params.id - const worktreeSelection = props.newSessionWorktree ?? "main" - - let sessionDirectory = projectDirectory - let client = sdk.client - - if (isNewSession) { - if (worktreeSelection === "create") { - const createdWorktree = await client.worktree - .create({ directory: projectDirectory }) - .then((x) => x.data) - .catch((err) => { - showToast({ - title: language.t("prompt.toast.worktreeCreateFailed.title"), - description: errorMessage(err), - }) - return undefined - }) - - if (!createdWorktree?.directory) { - showToast({ - title: language.t("prompt.toast.worktreeCreateFailed.title"), - description: language.t("common.requestFailed"), - }) - return - } - WorktreeState.pending(createdWorktree.directory) - sessionDirectory = createdWorktree.directory - } - - if (worktreeSelection !== "main" && worktreeSelection !== "create") { - sessionDirectory = worktreeSelection - } - - if (sessionDirectory !== projectDirectory) { - client = createOpencodeClient({ - baseUrl: sdk.url, - fetch: platform.fetch, - directory: sessionDirectory, - throwOnError: true, - }) - globalSync.child(sessionDirectory) - } - - props.onNewSessionWorktreeReset?.() - } - - let session = info() - if (!session && isNewSession) { - session = await client.session - .create() - .then((x) => x.data ?? undefined) - .catch((err) => { - showToast({ - title: language.t("prompt.toast.sessionCreateFailed.title"), - description: errorMessage(err), - }) - return undefined - }) - if (session) { - layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) - navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) - } - } - if (!session) return - - props.onSubmit?.() - - const model = { - modelID: currentModel.id, - providerID: currentModel.provider.id, - } - const agent = currentAgent.name - const variant = local.model.variant.current() - - const clearInput = () => { - prompt.reset() - setStore("mode", "normal") - setStore("popover", null) - } - - const restoreInput = () => { - prompt.set(currentPrompt, promptLength(currentPrompt)) - setStore("mode", mode) - setStore("popover", null) - requestAnimationFrame(() => { - editorRef.focus() - setCursorPosition(editorRef, promptLength(currentPrompt)) - queueScroll() - }) - } - - if (mode === "shell") { - clearInput() - client.session - .shell({ - sessionID: session.id, - agent, - model, - command: text, - }) - .catch((err) => { - showToast({ - title: language.t("prompt.toast.shellSendFailed.title"), - description: errorMessage(err), - }) - restoreInput() - }) - return - } - - if (text.startsWith("/")) { - const [cmdName, ...args] = text.split(" ") - const commandName = cmdName.slice(1) - const customCommand = sync.data.command.find((c) => c.name === commandName) - if (customCommand) { - clearInput() - client.session - .command({ - sessionID: session.id, - command: commandName, - arguments: args.join(" "), - agent, - model: `${model.providerID}/${model.modelID}`, - variant, - parts: images.map((attachment) => ({ - id: Identifier.ascending("part"), - type: "file" as const, - mime: attachment.mime, - url: attachment.dataUrl, - filename: attachment.filename, - })), - }) - .catch((err) => { - showToast({ - title: language.t("prompt.toast.commandSendFailed.title"), - description: errorMessage(err), - }) - restoreInput() - }) - return - } - } - - const toAbsolutePath = (path: string) => - path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/") - - const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[] - const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[] - - const fileAttachmentParts = fileAttachments.map((attachment) => { - const absolute = toAbsolutePath(attachment.path) - const query = attachment.selection - ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}` - : "" - return { - id: Identifier.ascending("part"), - type: "file" as const, - mime: "text/plain", - url: `file://${absolute}${query}`, - filename: getFilename(attachment.path), - source: { - type: "file" as const, - text: { - value: attachment.content, - start: attachment.start, - end: attachment.end, - }, - path: absolute, - }, - } - }) - - const agentAttachmentParts = agentAttachments.map((attachment) => ({ - id: Identifier.ascending("part"), - type: "agent" as const, - name: attachment.name, - source: { - value: attachment.content, - start: attachment.start, - end: attachment.end, - }, - })) - - const usedUrls = new Set(fileAttachmentParts.map((part) => part.url)) - - const context = prompt.context.items().slice() - - const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) - - const contextParts: Array< - | { - id: string - type: "text" - text: string - synthetic?: boolean - } - | { - id: string - type: "file" - mime: string - url: string - filename?: string - } - > = [] - - const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => { - const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined - const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined - const range = - start === undefined || end === undefined - ? "this file" - : start === end - ? `line ${start}` - : `lines ${start} through ${end}` - - return `The user made the following comment regarding ${range} of ${path}: ${comment}` - } - - const addContextFile = (input: { path: string; selection?: FileSelection; comment?: string }) => { - const absolute = toAbsolutePath(input.path) - const query = input.selection ? `?start=${input.selection.startLine}&end=${input.selection.endLine}` : "" - const url = `file://${absolute}${query}` - - const comment = input.comment?.trim() - if (!comment && usedUrls.has(url)) return - usedUrls.add(url) - - if (comment) { - contextParts.push({ - id: Identifier.ascending("part"), - type: "text", - text: commentNote(input.path, input.selection, comment), - synthetic: true, - }) - } - - contextParts.push({ - id: Identifier.ascending("part"), - type: "file", - mime: "text/plain", - url, - filename: getFilename(input.path), - }) - } - - for (const item of context) { - if (item.type !== "file") continue - addContextFile({ path: item.path, selection: item.selection, comment: item.comment }) - } - - const imageAttachmentParts = images.map((attachment) => ({ - id: Identifier.ascending("part"), - type: "file" as const, - mime: attachment.mime, - url: attachment.dataUrl, - filename: attachment.filename, - })) - - const messageID = Identifier.ascending("message") - const textPart = { - id: Identifier.ascending("part"), - type: "text" as const, - text, - } - const requestParts = [ - textPart, - ...fileAttachmentParts, - ...contextParts, - ...agentAttachmentParts, - ...imageAttachmentParts, - ] - - const optimisticParts = requestParts.map((part) => ({ - ...part, - sessionID: session.id, - messageID, - })) as unknown as Part[] - - const optimisticMessage: Message = { - id: messageID, - sessionID: session.id, - role: "user", - time: { created: Date.now() }, - agent, - model, - } - - const addOptimisticMessage = () => { - if (sessionDirectory === projectDirectory) { - sync.set( - produce((draft) => { - const messages = draft.message[session.id] - if (!messages) { - draft.message[session.id] = [optimisticMessage] - } else { - const result = Binary.search(messages, messageID, (m) => m.id) - messages.splice(result.index, 0, optimisticMessage) - } - draft.part[messageID] = optimisticParts - .filter((p) => !!p?.id) - .slice() - .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - }), - ) - return - } - - globalSync.child(sessionDirectory)[1]( - produce((draft) => { - const messages = draft.message[session.id] - if (!messages) { - draft.message[session.id] = [optimisticMessage] - } else { - const result = Binary.search(messages, messageID, (m) => m.id) - messages.splice(result.index, 0, optimisticMessage) - } - draft.part[messageID] = optimisticParts - .filter((p) => !!p?.id) - .slice() - .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - }), - ) - } - - const removeOptimisticMessage = () => { - if (sessionDirectory === projectDirectory) { - sync.set( - produce((draft) => { - const messages = draft.message[session.id] - if (messages) { - const result = Binary.search(messages, messageID, (m) => m.id) - if (result.found) messages.splice(result.index, 1) - } - delete draft.part[messageID] - }), - ) - return - } - - globalSync.child(sessionDirectory)[1]( - produce((draft) => { - const messages = draft.message[session.id] - if (messages) { - const result = Binary.search(messages, messageID, (m) => m.id) - if (result.found) messages.splice(result.index, 1) - } - delete draft.part[messageID] - }), - ) - } - - for (const item of commentItems) { - prompt.context.remove(item.key) - } - - clearInput() - addOptimisticMessage() - - const waitForWorktree = async () => { - const worktree = WorktreeState.get(sessionDirectory) - if (!worktree || worktree.status !== "pending") return true - - if (sessionDirectory === projectDirectory) { - sync.set("session_status", session.id, { type: "busy" }) - } - - const controller = new AbortController() - - const cleanup = () => { - if (sessionDirectory === projectDirectory) { - sync.set("session_status", session.id, { type: "idle" }) - } - removeOptimisticMessage() - for (const item of commentItems) { - prompt.context.add({ - type: "file", - path: item.path, - selection: item.selection, - comment: item.comment, - commentID: item.commentID, - commentOrigin: item.commentOrigin, - preview: item.preview, - }) - } - restoreInput() - } - - pending.set(session.id, { abort: controller, cleanup }) - - const abort = new Promise>>((resolve) => { - if (controller.signal.aborted) { - resolve({ status: "failed", message: "aborted" }) - return - } - controller.signal.addEventListener( - "abort", - () => { - resolve({ status: "failed", message: "aborted" }) - }, - { once: true }, - ) - }) - - const timeoutMs = 5 * 60 * 1000 - const timer = { id: undefined as number | undefined } - const timeout = new Promise>>((resolve) => { - timer.id = window.setTimeout(() => { - resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") }) - }, timeoutMs) - }) - - const result = await Promise.race([WorktreeState.wait(sessionDirectory), abort, timeout]).finally(() => { - if (timer.id === undefined) return - clearTimeout(timer.id) - }) - pending.delete(session.id) - if (controller.signal.aborted) return false - if (result.status === "failed") throw new Error(result.message) - return true - } - - const send = async () => { - const ok = await waitForWorktree() - if (!ok) return - await client.session.prompt({ - sessionID: session.id, - agent, - model, - messageID, - parts: requestParts, - variant, - }) - } - - void send().catch((err) => { - pending.delete(session.id) - if (sessionDirectory === projectDirectory) { - sync.set("session_status", session.id, { type: "idle" }) - } - showToast({ - title: language.t("prompt.toast.promptSendFailed.title"), - description: errorMessage(err), - }) - removeOptimisticMessage() - for (const item of commentItems) { - prompt.context.add({ - type: "file", - path: item.path, - selection: item.selection, - comment: item.comment, - commentID: item.commentID, - commentOrigin: item.commentOrigin, - preview: item.preview, - }) - } - restoreInput() - }) - } - return (
- -
{ - if (store.popover === "slash") slashPopoverRef = el - }} - class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10 - overflow-auto no-scrollbar flex flex-col p-2 rounded-md - border border-border-base bg-surface-raised-stronger-non-alpha shadow-md" - onMouseDown={(e) => e.preventDefault()} - > - - - 0} - fallback={
{language.t("prompt.popover.emptyResults")}
} - > - - {(item) => ( - - )} - -
-
- - 0} - fallback={
{language.t("prompt.popover.emptyCommands")}
} - > - - {(cmd) => ( - - )} - -
-
-
-
-
+ (slashPopoverRef = el)} + atFlat={atFlat()} + atActive={atActive() ?? undefined} + atKey={atKey} + setAtActive={setAtActive} + onAtSelect={handleAtSelect} + slashFlat={slashFlat()} + slashActive={slashActive() ?? undefined} + setSlashActive={setSlashActive} + onSlashSelect={handleSlashSelect} + commandKeybind={command.keybind} + t={(key) => language.t(key as Parameters[0])} + />
= (props) => { [props.class ?? ""]: !!props.class, }} > - -
-
- - {language.t("prompt.dropzone.label")} -
-
-
- 0}> -
- - {(item) => { - const active = () => { - const a = comments.active() - return !!item.commentID && item.commentID === a?.id && item.path === a?.file - } - return ( - - - {getDirectory(item.path)} - - {getFilename(item.path)} - - } - placement="top" - openDelay={2000} - > -
{ - openComment(item) - }} - > -
- -
- {getFilenameTruncated(item.path, 14)} - - {(sel) => ( - - {sel().startLine === sel().endLine - ? `:${sel().startLine}` - : `:${sel().startLine}-${sel().endLine}`} - - )} - -
- { - e.stopPropagation() - if (item.commentID) comments.remove(item.path, item.commentID) - prompt.context.remove(item.key) - }} - aria-label={language.t("prompt.context.removeFile")} - /> -
- - {(comment) => ( -
{comment()}
- )} -
-
-
- ) - }} -
-
-
- 0}> -
- - {(attachment) => ( -
- - -
- } - > - {attachment.filename} - dialog.show(() => ) - } - /> - - -
- {attachment.filename} -
-
- )} - -
- + + { + const active = comments.active() + return !!item.commentID && item.commentID === active?.id && item.path === active?.file + }} + openComment={openComment} + remove={(item) => { + if (item.commentID) comments.remove(item.path, item.commentID) + prompt.context.remove(item.key) + }} + t={(key) => language.t(key as Parameters[0])} + /> + + dialog.show(() => ) + } + onRemove={removeImageAttachment} + removeLabel={language.t("prompt.attachment.remove")} + />
(scrollRef = el)}>
= (props) => { }} role="textbox" aria-multiline="true" - aria-label={ - store.mode === "shell" - ? language.t("prompt.placeholder.shell") - : commentCount() > 1 - ? language.t("prompt.placeholder.summarizeComments") - : commentCount() === 1 - ? language.t("prompt.placeholder.summarizeComment") - : language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) }) - } + aria-label={placeholder()} contenteditable="true" onInput={handleInput} onPaste={handlePaste} @@ -1892,13 +998,7 @@ export const PromptInput: Component = (props) => { />
- {store.mode === "shell" - ? language.t("prompt.placeholder.shell") - : commentCount() > 1 - ? language.t("prompt.placeholder.summarizeComments") - : commentCount() === 1 - ? language.t("prompt.placeholder.summarizeComment") - : language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })} + {placeholder()}
@@ -1923,7 +1023,7 @@ export const PromptInput: Component = (props) => { options={local.agent.list().map((agent) => agent.name)} current={local.agent.current()?.name ?? ""} onSelect={local.agent.set} - class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-[80px]" : "max-w-[120px]"}`} + class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-full" : "max-w-[120px]"}`} valueClass="truncate" variant="ghost" /> @@ -2087,109 +1187,3 @@ export const PromptInput: Component = (props) => {
) } - -function createTextFragment(content: string): DocumentFragment { - const fragment = document.createDocumentFragment() - const segments = content.split("\n") - segments.forEach((segment, index) => { - if (segment) { - fragment.appendChild(document.createTextNode(segment)) - } else if (segments.length > 1) { - fragment.appendChild(document.createTextNode("\u200B")) - } - if (index < segments.length - 1) { - fragment.appendChild(document.createElement("br")) - } - }) - return fragment -} - -function getNodeLength(node: Node): number { - if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 - return (node.textContent ?? "").replace(/\u200B/g, "").length -} - -function getTextLength(node: Node): number { - if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length - if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 - let length = 0 - for (const child of Array.from(node.childNodes)) { - length += getTextLength(child) - } - return length -} - -function getCursorPosition(parent: HTMLElement): number { - const selection = window.getSelection() - if (!selection || selection.rangeCount === 0) return 0 - const range = selection.getRangeAt(0) - if (!parent.contains(range.startContainer)) return 0 - const preCaretRange = range.cloneRange() - preCaretRange.selectNodeContents(parent) - preCaretRange.setEnd(range.startContainer, range.startOffset) - return getTextLength(preCaretRange.cloneContents()) -} - -function setCursorPosition(parent: HTMLElement, position: number) { - let remaining = position - let node = parent.firstChild - while (node) { - const length = getNodeLength(node) - const isText = node.nodeType === Node.TEXT_NODE - const isPill = - node.nodeType === Node.ELEMENT_NODE && - ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent") - const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" - - if (isText && remaining <= length) { - const range = document.createRange() - const selection = window.getSelection() - range.setStart(node, remaining) - range.collapse(true) - selection?.removeAllRanges() - selection?.addRange(range) - return - } - - if ((isPill || isBreak) && remaining <= length) { - const range = document.createRange() - const selection = window.getSelection() - if (remaining === 0) { - range.setStartBefore(node) - } - if (remaining > 0 && isPill) { - range.setStartAfter(node) - } - if (remaining > 0 && isBreak) { - const next = node.nextSibling - if (next && next.nodeType === Node.TEXT_NODE) { - range.setStart(next, 0) - } - if (!next || next.nodeType !== Node.TEXT_NODE) { - range.setStartAfter(node) - } - } - range.collapse(true) - selection?.removeAllRanges() - selection?.addRange(range) - return - } - - remaining -= length - node = node.nextSibling - } - - const fallbackRange = document.createRange() - const fallbackSelection = window.getSelection() - const last = parent.lastChild - if (last && last.nodeType === Node.TEXT_NODE) { - const len = last.textContent ? last.textContent.length : 0 - fallbackRange.setStart(last, len) - } - if (!last || last.nodeType !== Node.TEXT_NODE) { - fallbackRange.selectNodeContents(parent) - } - fallbackRange.collapse(false) - fallbackSelection?.removeAllRanges() - fallbackSelection?.addRange(fallbackRange) -} diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts new file mode 100644 index 0000000000..4ea2cfb90f --- /dev/null +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -0,0 +1,132 @@ +import { onCleanup, onMount } from "solid-js" +import { showToast } from "@opencode-ai/ui/toast" +import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt" +import { useLanguage } from "@/context/language" +import { getCursorPosition } from "./editor-dom" + +export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] +export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] + +type PromptAttachmentsInput = { + editor: () => HTMLDivElement | undefined + isFocused: () => boolean + isDialogActive: () => boolean + setDragging: (value: boolean) => void + addPart: (part: ContentPart) => void +} + +export function createPromptAttachments(input: PromptAttachmentsInput) { + const prompt = usePrompt() + const language = useLanguage() + + const addImageAttachment = async (file: File) => { + if (!ACCEPTED_FILE_TYPES.includes(file.type)) return + + const reader = new FileReader() + reader.onload = () => { + const editor = input.editor() + if (!editor) return + const dataUrl = reader.result as string + const attachment: ImageAttachmentPart = { + type: "image", + id: crypto.randomUUID(), + filename: file.name, + mime: file.type, + dataUrl, + } + const cursorPosition = prompt.cursor() ?? getCursorPosition(editor) + prompt.set([...prompt.current(), attachment], cursorPosition) + } + reader.readAsDataURL(file) + } + + const removeImageAttachment = (id: string) => { + const current = prompt.current() + const next = current.filter((part) => part.type !== "image" || part.id !== id) + prompt.set(next, prompt.cursor()) + } + + const handlePaste = async (event: ClipboardEvent) => { + if (!input.isFocused()) return + const clipboardData = event.clipboardData + if (!clipboardData) return + + event.preventDefault() + event.stopPropagation() + + const items = Array.from(clipboardData.items) + const fileItems = items.filter((item) => item.kind === "file") + const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type)) + + if (imageItems.length > 0) { + for (const item of imageItems) { + const file = item.getAsFile() + if (file) await addImageAttachment(file) + } + return + } + + if (fileItems.length > 0) { + showToast({ + title: language.t("prompt.toast.pasteUnsupported.title"), + description: language.t("prompt.toast.pasteUnsupported.description"), + }) + return + } + + const plainText = clipboardData.getData("text/plain") ?? "" + if (!plainText) return + input.addPart({ type: "text", content: plainText, start: 0, end: 0 }) + } + + const handleGlobalDragOver = (event: DragEvent) => { + if (input.isDialogActive()) return + + event.preventDefault() + const hasFiles = event.dataTransfer?.types.includes("Files") + if (hasFiles) { + input.setDragging(true) + } + } + + const handleGlobalDragLeave = (event: DragEvent) => { + if (input.isDialogActive()) return + if (!event.relatedTarget) { + input.setDragging(false) + } + } + + const handleGlobalDrop = async (event: DragEvent) => { + if (input.isDialogActive()) return + + event.preventDefault() + input.setDragging(false) + + const dropped = event.dataTransfer?.files + if (!dropped) return + + for (const file of Array.from(dropped)) { + if (ACCEPTED_FILE_TYPES.includes(file.type)) { + await addImageAttachment(file) + } + } + } + + onMount(() => { + document.addEventListener("dragover", handleGlobalDragOver) + document.addEventListener("dragleave", handleGlobalDragLeave) + document.addEventListener("drop", handleGlobalDrop) + }) + + onCleanup(() => { + document.removeEventListener("dragover", handleGlobalDragOver) + document.removeEventListener("dragleave", handleGlobalDragLeave) + document.removeEventListener("drop", handleGlobalDrop) + }) + + return { + addImageAttachment, + removeImageAttachment, + handlePaste, + } +} diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts new file mode 100644 index 0000000000..b284c38841 --- /dev/null +++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test" +import type { Prompt } from "@/context/prompt" +import { buildRequestParts } from "./build-request-parts" + +describe("buildRequestParts", () => { + test("builds typed request and optimistic parts without cast path", () => { + const prompt: Prompt = [ + { type: "text", content: "hello", start: 0, end: 5 }, + { + type: "file", + path: "src/foo.ts", + content: "@src/foo.ts", + start: 5, + end: 16, + selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 }, + }, + { type: "agent", name: "planner", content: "@planner", start: 16, end: 24 }, + ] + + const result = buildRequestParts({ + prompt, + context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }], + images: [ + { type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" }, + ], + text: "hello @src/foo.ts @planner", + messageID: "msg_1", + sessionID: "ses_1", + sessionDirectory: "/repo", + }) + + expect(result.requestParts[0]?.type).toBe("text") + expect(result.requestParts.some((part) => part.type === "agent")).toBe(true) + expect( + result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")), + ).toBe(true) + expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true) + + expect(result.optimisticParts).toHaveLength(result.requestParts.length) + expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true) + }) + + test("deduplicates context files when prompt already includes same path", () => { + const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }] + + const result = buildRequestParts({ + prompt, + context: [ + { key: "ctx:dup", type: "file", path: "src/foo.ts" }, + { key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" }, + ], + images: [], + text: "@src/foo.ts", + messageID: "msg_2", + sessionID: "ses_2", + sessionDirectory: "/repo", + }) + + const fooFiles = result.requestParts.filter( + (part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts"), + ) + const synthetic = result.requestParts.filter((part) => part.type === "text" && part.synthetic) + + expect(fooFiles).toHaveLength(2) + expect(synthetic).toHaveLength(1) + }) +}) diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts new file mode 100644 index 0000000000..7010a1fd84 --- /dev/null +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -0,0 +1,180 @@ +import { getFilename } from "@opencode-ai/util/path" +import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client" +import type { FileSelection } from "@/context/file" +import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" +import { Identifier } from "@/utils/id" + +type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string } + +type ContextFile = { + key: string + type: "file" + path: string + selection?: FileSelection + comment?: string + commentID?: string + commentOrigin?: "review" | "file" + preview?: string +} + +type BuildRequestPartsInput = { + prompt: Prompt + context: ContextFile[] + images: ImageAttachmentPart[] + text: string + messageID: string + sessionID: string + sessionDirectory: string +} + +const absolute = (directory: string, path: string) => + path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/") + +const encodeFilePath = (filepath: string): string => + filepath + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/") + +const fileQuery = (selection: FileSelection | undefined) => + selection ? `?start=${selection.startLine}&end=${selection.endLine}` : "" + +const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file" +const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent" + +const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => { + const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined + const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined + const range = + start === undefined || end === undefined + ? "this file" + : start === end + ? `line ${start}` + : `lines ${start} through ${end}` + return `The user made the following comment regarding ${range} of ${path}: ${comment}` +} + +const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => { + if (part.type === "text") { + return { + id: part.id, + type: "text", + text: part.text, + synthetic: part.synthetic, + ignored: part.ignored, + time: part.time, + metadata: part.metadata, + sessionID, + messageID, + } + } + if (part.type === "file") { + return { + id: part.id, + type: "file", + mime: part.mime, + filename: part.filename, + url: part.url, + source: part.source, + sessionID, + messageID, + } + } + return { + id: part.id, + type: "agent", + name: part.name, + source: part.source, + sessionID, + messageID, + } +} + +export function buildRequestParts(input: BuildRequestPartsInput) { + const requestParts: PromptRequestPart[] = [ + { + id: Identifier.ascending("part"), + type: "text", + text: input.text, + }, + ] + + const files = input.prompt.filter(isFileAttachment).map((attachment) => { + const path = absolute(input.sessionDirectory, attachment.path) + return { + id: Identifier.ascending("part"), + type: "file", + mime: "text/plain", + url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`, + filename: getFilename(attachment.path), + source: { + type: "file", + text: { + value: attachment.content, + start: attachment.start, + end: attachment.end, + }, + path, + }, + } satisfies PromptRequestPart + }) + + const agents = input.prompt.filter(isAgentAttachment).map((attachment) => { + return { + id: Identifier.ascending("part"), + type: "agent", + name: attachment.name, + source: { + value: attachment.content, + start: attachment.start, + end: attachment.end, + }, + } satisfies PromptRequestPart + }) + + const used = new Set(files.map((part) => part.url)) + const context = input.context.flatMap((item) => { + const path = absolute(input.sessionDirectory, item.path) + const url = `file://${encodeFilePath(path)}${fileQuery(item.selection)}` + const comment = item.comment?.trim() + if (!comment && used.has(url)) return [] + used.add(url) + + const filePart = { + id: Identifier.ascending("part"), + type: "file", + mime: "text/plain", + url, + filename: getFilename(item.path), + } satisfies PromptRequestPart + + if (!comment) return [filePart] + + return [ + { + id: Identifier.ascending("part"), + type: "text", + text: commentNote(item.path, item.selection, comment), + synthetic: true, + } satisfies PromptRequestPart, + filePart, + ] + }) + + const images = input.images.map((attachment) => { + return { + id: Identifier.ascending("part"), + type: "file", + mime: attachment.mime, + url: attachment.dataUrl, + filename: attachment.filename, + } satisfies PromptRequestPart + }) + + requestParts.push(...files, ...context, ...agents, ...images) + + return { + requestParts, + optimisticParts: requestParts.map((part) => toOptimisticPart(part, input.sessionID, input.messageID)), + } +} diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx new file mode 100644 index 0000000000..a843e109d8 --- /dev/null +++ b/packages/app/src/components/prompt-input/context-items.tsx @@ -0,0 +1,82 @@ +import { Component, For, Show } from "solid-js" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path" +import type { ContextItem } from "@/context/prompt" + +type PromptContextItem = ContextItem & { key: string } + +type ContextItemsProps = { + items: PromptContextItem[] + active: (item: PromptContextItem) => boolean + openComment: (item: PromptContextItem) => void + remove: (item: PromptContextItem) => void + t: (key: string) => string +} + +export const PromptContextItems: Component = (props) => { + return ( + 0}> +
+ + {(item) => ( + + + {getDirectory(item.path)} + + {getFilename(item.path)} + + } + placement="top" + openDelay={2000} + > +
props.openComment(item)} + > +
+ +
+ {getFilenameTruncated(item.path, 14)} + + {(sel) => ( + + {sel().startLine === sel().endLine + ? `:${sel().startLine}` + : `:${sel().startLine}-${sel().endLine}`} + + )} + +
+ { + e.stopPropagation() + props.remove(item) + }} + aria-label={props.t("prompt.context.removeFile")} + /> +
+ + {(comment) =>
{comment()}
} +
+
+
+ )} +
+
+
+ ) +} diff --git a/packages/app/src/components/prompt-input/drag-overlay.tsx b/packages/app/src/components/prompt-input/drag-overlay.tsx new file mode 100644 index 0000000000..f5a4d399ef --- /dev/null +++ b/packages/app/src/components/prompt-input/drag-overlay.tsx @@ -0,0 +1,20 @@ +import { Component, Show } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" + +type PromptDragOverlayProps = { + dragging: boolean + label: string +} + +export const PromptDragOverlay: Component = (props) => { + return ( + +
+
+ + {props.label} +
+
+
+ ) +} diff --git a/packages/app/src/components/prompt-input/editor-dom.test.ts b/packages/app/src/components/prompt-input/editor-dom.test.ts new file mode 100644 index 0000000000..fce8b4b953 --- /dev/null +++ b/packages/app/src/components/prompt-input/editor-dom.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test" +import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom" + +describe("prompt-input editor dom", () => { + test("createTextFragment preserves newlines with br and zero-width placeholders", () => { + const fragment = createTextFragment("foo\n\nbar") + const container = document.createElement("div") + container.appendChild(fragment) + + expect(container.childNodes.length).toBe(5) + expect(container.childNodes[0]?.textContent).toBe("foo") + expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR") + expect(container.childNodes[2]?.textContent).toBe("\u200B") + expect((container.childNodes[3] as HTMLElement).tagName).toBe("BR") + expect(container.childNodes[4]?.textContent).toBe("bar") + }) + + test("length helpers treat breaks as one char and ignore zero-width chars", () => { + const container = document.createElement("div") + container.appendChild(document.createTextNode("ab\u200B")) + container.appendChild(document.createElement("br")) + container.appendChild(document.createTextNode("cd")) + + expect(getNodeLength(container.childNodes[0]!)).toBe(2) + expect(getNodeLength(container.childNodes[1]!)).toBe(1) + expect(getTextLength(container)).toBe(5) + }) + + test("setCursorPosition and getCursorPosition round-trip with pills and breaks", () => { + const container = document.createElement("div") + const pill = document.createElement("span") + pill.dataset.type = "file" + pill.textContent = "@file" + container.appendChild(document.createTextNode("ab")) + container.appendChild(pill) + container.appendChild(document.createElement("br")) + container.appendChild(document.createTextNode("cd")) + document.body.appendChild(container) + + setCursorPosition(container, 2) + expect(getCursorPosition(container)).toBe(2) + + setCursorPosition(container, 7) + expect(getCursorPosition(container)).toBe(7) + + setCursorPosition(container, 8) + expect(getCursorPosition(container)).toBe(8) + + container.remove() + }) +}) diff --git a/packages/app/src/components/prompt-input/editor-dom.ts b/packages/app/src/components/prompt-input/editor-dom.ts new file mode 100644 index 0000000000..3116ceb126 --- /dev/null +++ b/packages/app/src/components/prompt-input/editor-dom.ts @@ -0,0 +1,135 @@ +export function createTextFragment(content: string): DocumentFragment { + const fragment = document.createDocumentFragment() + const segments = content.split("\n") + segments.forEach((segment, index) => { + if (segment) { + fragment.appendChild(document.createTextNode(segment)) + } else if (segments.length > 1) { + fragment.appendChild(document.createTextNode("\u200B")) + } + if (index < segments.length - 1) { + fragment.appendChild(document.createElement("br")) + } + }) + return fragment +} + +export function getNodeLength(node: Node): number { + if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 + return (node.textContent ?? "").replace(/\u200B/g, "").length +} + +export function getTextLength(node: Node): number { + if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length + if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 + let length = 0 + for (const child of Array.from(node.childNodes)) { + length += getTextLength(child) + } + return length +} + +export function getCursorPosition(parent: HTMLElement): number { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return 0 + const range = selection.getRangeAt(0) + if (!parent.contains(range.startContainer)) return 0 + const preCaretRange = range.cloneRange() + preCaretRange.selectNodeContents(parent) + preCaretRange.setEnd(range.startContainer, range.startOffset) + return getTextLength(preCaretRange.cloneContents()) +} + +export function setCursorPosition(parent: HTMLElement, position: number) { + let remaining = position + let node = parent.firstChild + while (node) { + const length = getNodeLength(node) + const isText = node.nodeType === Node.TEXT_NODE + const isPill = + node.nodeType === Node.ELEMENT_NODE && + ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent") + const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" + + if (isText && remaining <= length) { + const range = document.createRange() + const selection = window.getSelection() + range.setStart(node, remaining) + range.collapse(true) + selection?.removeAllRanges() + selection?.addRange(range) + return + } + + if ((isPill || isBreak) && remaining <= length) { + const range = document.createRange() + const selection = window.getSelection() + if (remaining === 0) { + range.setStartBefore(node) + } + if (remaining > 0 && isPill) { + range.setStartAfter(node) + } + if (remaining > 0 && isBreak) { + const next = node.nextSibling + if (next && next.nodeType === Node.TEXT_NODE) { + range.setStart(next, 0) + } + if (!next || next.nodeType !== Node.TEXT_NODE) { + range.setStartAfter(node) + } + } + range.collapse(true) + selection?.removeAllRanges() + selection?.addRange(range) + return + } + + remaining -= length + node = node.nextSibling + } + + const fallbackRange = document.createRange() + const fallbackSelection = window.getSelection() + const last = parent.lastChild + if (last && last.nodeType === Node.TEXT_NODE) { + const len = last.textContent ? last.textContent.length : 0 + fallbackRange.setStart(last, len) + } + if (!last || last.nodeType !== Node.TEXT_NODE) { + fallbackRange.selectNodeContents(parent) + } + fallbackRange.collapse(false) + fallbackSelection?.removeAllRanges() + fallbackSelection?.addRange(fallbackRange) +} + +export function setRangeEdge(parent: HTMLElement, range: Range, edge: "start" | "end", offset: number) { + let remaining = offset + const nodes = Array.from(parent.childNodes) + + for (const node of nodes) { + const length = getNodeLength(node) + const isText = node.nodeType === Node.TEXT_NODE + const isPill = + node.nodeType === Node.ELEMENT_NODE && + ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent") + const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" + + if (isText && remaining <= length) { + if (edge === "start") range.setStart(node, remaining) + if (edge === "end") range.setEnd(node, remaining) + return + } + + if ((isPill || isBreak) && remaining <= length) { + if (edge === "start" && remaining === 0) range.setStartBefore(node) + if (edge === "start" && remaining > 0) range.setStartAfter(node) + if (edge === "end" && remaining === 0) range.setEndBefore(node) + if (edge === "end" && remaining > 0) range.setEndAfter(node) + return + } + + remaining -= length + } +} diff --git a/packages/app/src/components/prompt-input/history.test.ts b/packages/app/src/components/prompt-input/history.test.ts new file mode 100644 index 0000000000..54be9cb75b --- /dev/null +++ b/packages/app/src/components/prompt-input/history.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test" +import type { Prompt } from "@/context/prompt" +import { clonePromptParts, navigatePromptHistory, prependHistoryEntry, promptLength } from "./history" + +const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] + +const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }] + +describe("prompt-input history", () => { + test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => { + const first = prependHistoryEntry([], DEFAULT_PROMPT) + expect(first).toEqual([]) + + const withOne = prependHistoryEntry([], text("hello")) + expect(withOne).toHaveLength(1) + + const deduped = prependHistoryEntry(withOne, text("hello")) + expect(deduped).toBe(withOne) + }) + + test("navigatePromptHistory restores saved prompt when moving down from newest", () => { + const entries = [text("third"), text("second"), text("first")] + const up = navigatePromptHistory({ + direction: "up", + entries, + historyIndex: -1, + currentPrompt: text("draft"), + savedPrompt: null, + }) + expect(up.handled).toBe(true) + if (!up.handled) throw new Error("expected handled") + expect(up.historyIndex).toBe(0) + expect(up.cursor).toBe("start") + + const down = navigatePromptHistory({ + direction: "down", + entries, + historyIndex: up.historyIndex, + currentPrompt: text("ignored"), + savedPrompt: up.savedPrompt, + }) + expect(down.handled).toBe(true) + if (!down.handled) throw new Error("expected handled") + expect(down.historyIndex).toBe(-1) + expect(down.prompt[0]?.type === "text" ? down.prompt[0].content : "").toBe("draft") + }) + + test("helpers clone prompt and count text content length", () => { + const original: Prompt = [ + { type: "text", content: "one", start: 0, end: 3 }, + { + type: "file", + path: "src/a.ts", + content: "@src/a.ts", + start: 3, + end: 12, + selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 }, + }, + { type: "image", id: "1", filename: "img.png", mime: "image/png", dataUrl: "data:image/png;base64,abc" }, + ] + const copy = clonePromptParts(original) + expect(copy).not.toBe(original) + expect(promptLength(copy)).toBe(12) + if (copy[1]?.type !== "file") throw new Error("expected file") + copy[1].selection!.startLine = 9 + if (original[1]?.type !== "file") throw new Error("expected file") + expect(original[1].selection?.startLine).toBe(1) + }) +}) diff --git a/packages/app/src/components/prompt-input/history.ts b/packages/app/src/components/prompt-input/history.ts new file mode 100644 index 0000000000..63164f0ba3 --- /dev/null +++ b/packages/app/src/components/prompt-input/history.ts @@ -0,0 +1,160 @@ +import type { Prompt } from "@/context/prompt" + +const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] + +export const MAX_HISTORY = 100 + +export function clonePromptParts(prompt: Prompt): Prompt { + return prompt.map((part) => { + if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } + if (part.type === "agent") return { ...part } + return { + ...part, + selection: part.selection ? { ...part.selection } : undefined, + } + }) +} + +export function promptLength(prompt: Prompt) { + return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0) +} + +export function prependHistoryEntry(entries: Prompt[], prompt: Prompt, max = MAX_HISTORY) { + const text = prompt + .map((part) => ("content" in part ? part.content : "")) + .join("") + .trim() + const hasImages = prompt.some((part) => part.type === "image") + if (!text && !hasImages) return entries + + const entry = clonePromptParts(prompt) + const last = entries[0] + if (last && isPromptEqual(last, entry)) return entries + return [entry, ...entries].slice(0, max) +} + +function isPromptEqual(promptA: Prompt, promptB: Prompt) { + if (promptA.length !== promptB.length) return false + for (let i = 0; i < promptA.length; i++) { + const partA = promptA[i] + const partB = promptB[i] + if (partA.type !== partB.type) return false + if (partA.type === "text" && partA.content !== (partB.type === "text" ? partB.content : "")) return false + if (partA.type === "file") { + if (partA.path !== (partB.type === "file" ? partB.path : "")) return false + const a = partA.selection + const b = partB.type === "file" ? partB.selection : undefined + const sameSelection = + (!a && !b) || + (!!a && + !!b && + a.startLine === b.startLine && + a.startChar === b.startChar && + a.endLine === b.endLine && + a.endChar === b.endChar) + if (!sameSelection) return false + } + if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false + if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false + } + return true +} + +type HistoryNavInput = { + direction: "up" | "down" + entries: Prompt[] + historyIndex: number + currentPrompt: Prompt + savedPrompt: Prompt | null +} + +type HistoryNavResult = + | { + handled: false + historyIndex: number + savedPrompt: Prompt | null + } + | { + handled: true + historyIndex: number + savedPrompt: Prompt | null + prompt: Prompt + cursor: "start" | "end" + } + +export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult { + if (input.direction === "up") { + if (input.entries.length === 0) { + return { + handled: false, + historyIndex: input.historyIndex, + savedPrompt: input.savedPrompt, + } + } + + if (input.historyIndex === -1) { + return { + handled: true, + historyIndex: 0, + savedPrompt: clonePromptParts(input.currentPrompt), + prompt: input.entries[0], + cursor: "start", + } + } + + if (input.historyIndex < input.entries.length - 1) { + const next = input.historyIndex + 1 + return { + handled: true, + historyIndex: next, + savedPrompt: input.savedPrompt, + prompt: input.entries[next], + cursor: "start", + } + } + + return { + handled: false, + historyIndex: input.historyIndex, + savedPrompt: input.savedPrompt, + } + } + + if (input.historyIndex > 0) { + const next = input.historyIndex - 1 + return { + handled: true, + historyIndex: next, + savedPrompt: input.savedPrompt, + prompt: input.entries[next], + cursor: "end", + } + } + + if (input.historyIndex === 0) { + if (input.savedPrompt) { + return { + handled: true, + historyIndex: -1, + savedPrompt: null, + prompt: input.savedPrompt, + cursor: "end", + } + } + + return { + handled: true, + historyIndex: -1, + savedPrompt: null, + prompt: DEFAULT_PROMPT, + cursor: "end", + } + } + + return { + handled: false, + historyIndex: input.historyIndex, + savedPrompt: input.savedPrompt, + } +} diff --git a/packages/app/src/components/prompt-input/image-attachments.tsx b/packages/app/src/components/prompt-input/image-attachments.tsx new file mode 100644 index 0000000000..ba3addf0a1 --- /dev/null +++ b/packages/app/src/components/prompt-input/image-attachments.tsx @@ -0,0 +1,51 @@ +import { Component, For, Show } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" +import type { ImageAttachmentPart } from "@/context/prompt" + +type PromptImageAttachmentsProps = { + attachments: ImageAttachmentPart[] + onOpen: (attachment: ImageAttachmentPart) => void + onRemove: (id: string) => void + removeLabel: string +} + +export const PromptImageAttachments: Component = (props) => { + return ( + 0}> +
+ + {(attachment) => ( +
+ + +
+ } + > + {attachment.filename} props.onOpen(attachment)} + /> + + +
+ {attachment.filename} +
+
+ )} + +
+
+ ) +} diff --git a/packages/app/src/components/prompt-input/placeholder.test.ts b/packages/app/src/components/prompt-input/placeholder.test.ts new file mode 100644 index 0000000000..b633df8295 --- /dev/null +++ b/packages/app/src/components/prompt-input/placeholder.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test" +import { promptPlaceholder } from "./placeholder" + +describe("promptPlaceholder", () => { + const t = (key: string, params?: Record) => `${key}${params?.example ? `:${params.example}` : ""}` + + test("returns shell placeholder in shell mode", () => { + const value = promptPlaceholder({ + mode: "shell", + commentCount: 0, + example: "example", + t, + }) + expect(value).toBe("prompt.placeholder.shell") + }) + + test("returns summarize placeholders for comment context", () => { + expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe( + "prompt.placeholder.summarizeComment", + ) + expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe( + "prompt.placeholder.summarizeComments", + ) + }) + + test("returns default placeholder with example", () => { + const value = promptPlaceholder({ + mode: "normal", + commentCount: 0, + example: "translated-example", + t, + }) + expect(value).toBe("prompt.placeholder.normal:translated-example") + }) +}) diff --git a/packages/app/src/components/prompt-input/placeholder.ts b/packages/app/src/components/prompt-input/placeholder.ts new file mode 100644 index 0000000000..07f6a43b51 --- /dev/null +++ b/packages/app/src/components/prompt-input/placeholder.ts @@ -0,0 +1,13 @@ +type PromptPlaceholderInput = { + mode: "normal" | "shell" + commentCount: number + example: string + t: (key: string, params?: Record) => string +} + +export function promptPlaceholder(input: PromptPlaceholderInput) { + if (input.mode === "shell") return input.t("prompt.placeholder.shell") + if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments") + if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment") + return input.t("prompt.placeholder.normal", { example: input.example }) +} diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx new file mode 100644 index 0000000000..b97bb67522 --- /dev/null +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -0,0 +1,144 @@ +import { Component, For, Match, Show, Switch } from "solid-js" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { Icon } from "@opencode-ai/ui/icon" +import { getDirectory, getFilename } from "@opencode-ai/util/path" + +export type AtOption = + | { type: "agent"; name: string; display: string } + | { type: "file"; path: string; display: string; recent?: boolean } + +export interface SlashCommand { + id: string + trigger: string + title: string + description?: string + keybind?: string + type: "builtin" | "custom" + source?: "command" | "mcp" | "skill" +} + +type PromptPopoverProps = { + popover: "at" | "slash" | null + setSlashPopoverRef: (el: HTMLDivElement) => void + atFlat: AtOption[] + atActive?: string + atKey: (item: AtOption) => string + setAtActive: (id: string) => void + onAtSelect: (item: AtOption) => void + slashFlat: SlashCommand[] + slashActive?: string + setSlashActive: (id: string) => void + onSlashSelect: (item: SlashCommand) => void + commandKeybind: (id: string) => string | undefined + t: (key: string) => string +} + +export const PromptPopover: Component = (props) => { + return ( + +
{ + if (props.popover === "slash") props.setSlashPopoverRef(el) + }} + class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10 + overflow-auto no-scrollbar flex flex-col p-2 rounded-md + border border-border-base bg-surface-raised-stronger-non-alpha shadow-md" + onMouseDown={(e) => e.preventDefault()} + > + + + 0} + fallback={
{props.t("prompt.popover.emptyResults")}
} + > + + {(item) => ( + + )} + +
+
+ + 0} + fallback={
{props.t("prompt.popover.emptyCommands")}
} + > + + {(cmd) => ( + + )} + +
+
+
+
+
+ ) +} diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts new file mode 100644 index 0000000000..5ed5eedada --- /dev/null +++ b/packages/app/src/components/prompt-input/submit.ts @@ -0,0 +1,411 @@ +import { Accessor } from "solid-js" +import { useNavigate, useParams } from "@solidjs/router" +import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client" +import { showToast } from "@opencode-ai/ui/toast" +import { base64Encode } from "@opencode-ai/util/encode" +import { useLocal } from "@/context/local" +import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt" +import { useLayout } from "@/context/layout" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { useGlobalSync } from "@/context/global-sync" +import { usePlatform } from "@/context/platform" +import { useLanguage } from "@/context/language" +import { Identifier } from "@/utils/id" +import { Worktree as WorktreeState } from "@/utils/worktree" +import type { FileSelection } from "@/context/file" +import { setCursorPosition } from "./editor-dom" +import { buildRequestParts } from "./build-request-parts" + +type PendingPrompt = { + abort: AbortController + cleanup: VoidFunction +} + +const pending = new Map() + +type PromptSubmitInput = { + info: Accessor<{ id: string } | undefined> + imageAttachments: Accessor + commentCount: Accessor + mode: Accessor<"normal" | "shell"> + working: Accessor + editor: () => HTMLDivElement | undefined + queueScroll: () => void + promptLength: (prompt: Prompt) => number + addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void + resetHistoryNavigation: () => void + setMode: (mode: "normal" | "shell") => void + setPopover: (popover: "at" | "slash" | null) => void + newSessionWorktree?: string + onNewSessionWorktreeReset?: () => void + onSubmit?: () => void +} + +type CommentItem = { + path: string + selection?: FileSelection + comment?: string + commentID?: string + commentOrigin?: "review" | "file" + preview?: string +} + +export function createPromptSubmit(input: PromptSubmitInput) { + const navigate = useNavigate() + const sdk = useSDK() + const sync = useSync() + const globalSync = useGlobalSync() + const platform = usePlatform() + const local = useLocal() + const prompt = usePrompt() + const layout = useLayout() + const language = useLanguage() + const params = useParams() + + const errorMessage = (err: unknown) => { + if (err && typeof err === "object" && "data" in err) { + const data = (err as { data?: { message?: string } }).data + if (data?.message) return data.message + } + if (err instanceof Error) return err.message + return language.t("common.requestFailed") + } + + const abort = async () => { + const sessionID = params.id + if (!sessionID) return Promise.resolve() + const queued = pending.get(sessionID) + if (queued) { + queued.abort.abort() + queued.cleanup() + pending.delete(sessionID) + return Promise.resolve() + } + return sdk.client.session + .abort({ + sessionID, + }) + .catch(() => {}) + } + + const restoreCommentItems = (items: CommentItem[]) => { + for (const item of items) { + prompt.context.add({ + type: "file", + path: item.path, + selection: item.selection, + comment: item.comment, + commentID: item.commentID, + commentOrigin: item.commentOrigin, + preview: item.preview, + }) + } + } + + const removeCommentItems = (items: { key: string }[]) => { + for (const item of items) { + prompt.context.remove(item.key) + } + } + + const handleSubmit = async (event: Event) => { + event.preventDefault() + + const currentPrompt = prompt.current() + const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") + const images = input.imageAttachments().slice() + const mode = input.mode() + + if (text.trim().length === 0 && images.length === 0 && input.commentCount() === 0) { + if (input.working()) abort() + return + } + + const currentModel = local.model.current() + const currentAgent = local.agent.current() + if (!currentModel || !currentAgent) { + showToast({ + title: language.t("prompt.toast.modelAgentRequired.title"), + description: language.t("prompt.toast.modelAgentRequired.description"), + }) + return + } + + input.addToHistory(currentPrompt, mode) + input.resetHistoryNavigation() + + const projectDirectory = sdk.directory + const isNewSession = !params.id + const worktreeSelection = input.newSessionWorktree ?? "main" + + let sessionDirectory = projectDirectory + let client = sdk.client + + if (isNewSession) { + if (worktreeSelection === "create") { + const createdWorktree = await client.worktree + .create({ directory: projectDirectory }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: language.t("prompt.toast.worktreeCreateFailed.title"), + description: errorMessage(err), + }) + return undefined + }) + + if (!createdWorktree?.directory) { + showToast({ + title: language.t("prompt.toast.worktreeCreateFailed.title"), + description: language.t("common.requestFailed"), + }) + return + } + WorktreeState.pending(createdWorktree.directory) + sessionDirectory = createdWorktree.directory + } + + if (worktreeSelection !== "main" && worktreeSelection !== "create") { + sessionDirectory = worktreeSelection + } + + if (sessionDirectory !== projectDirectory) { + client = createOpencodeClient({ + baseUrl: sdk.url, + fetch: platform.fetch, + directory: sessionDirectory, + throwOnError: true, + }) + globalSync.child(sessionDirectory) + } + + input.onNewSessionWorktreeReset?.() + } + + let session = input.info() + if (!session && isNewSession) { + session = await client.session + .create() + .then((x) => x.data ?? undefined) + .catch((err) => { + showToast({ + title: language.t("prompt.toast.sessionCreateFailed.title"), + description: errorMessage(err), + }) + return undefined + }) + if (session) { + layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) + navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) + } + } + if (!session) return + + input.onSubmit?.() + + const model = { + modelID: currentModel.id, + providerID: currentModel.provider.id, + } + const agent = currentAgent.name + const variant = local.model.variant.current() + + const clearInput = () => { + prompt.reset() + input.setMode("normal") + input.setPopover(null) + } + + const restoreInput = () => { + prompt.set(currentPrompt, input.promptLength(currentPrompt)) + input.setMode(mode) + input.setPopover(null) + requestAnimationFrame(() => { + const editor = input.editor() + if (!editor) return + editor.focus() + setCursorPosition(editor, input.promptLength(currentPrompt)) + input.queueScroll() + }) + } + + if (mode === "shell") { + clearInput() + client.session + .shell({ + sessionID: session.id, + agent, + model, + command: text, + }) + .catch((err) => { + showToast({ + title: language.t("prompt.toast.shellSendFailed.title"), + description: errorMessage(err), + }) + restoreInput() + }) + return + } + + if (text.startsWith("/")) { + const [cmdName, ...args] = text.split(" ") + const commandName = cmdName.slice(1) + const customCommand = sync.data.command.find((c) => c.name === commandName) + if (customCommand) { + clearInput() + client.session + .command({ + sessionID: session.id, + command: commandName, + arguments: args.join(" "), + agent, + model: `${model.providerID}/${model.modelID}`, + variant, + parts: images.map((attachment) => ({ + id: Identifier.ascending("part"), + type: "file" as const, + mime: attachment.mime, + url: attachment.dataUrl, + filename: attachment.filename, + })), + }) + .catch((err) => { + showToast({ + title: language.t("prompt.toast.commandSendFailed.title"), + description: errorMessage(err), + }) + restoreInput() + }) + return + } + } + + const context = prompt.context.items().slice() + const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) + + const messageID = Identifier.ascending("message") + const { requestParts, optimisticParts } = buildRequestParts({ + prompt: currentPrompt, + context, + images, + text, + sessionID: session.id, + messageID, + sessionDirectory, + }) + + const optimisticMessage: Message = { + id: messageID, + sessionID: session.id, + role: "user", + time: { created: Date.now() }, + agent, + model, + } + + const addOptimisticMessage = () => + sync.session.optimistic.add({ + directory: sessionDirectory, + sessionID: session.id, + message: optimisticMessage, + parts: optimisticParts, + }) + + const removeOptimisticMessage = () => + sync.session.optimistic.remove({ + directory: sessionDirectory, + sessionID: session.id, + messageID, + }) + + removeCommentItems(commentItems) + clearInput() + addOptimisticMessage() + + const waitForWorktree = async () => { + const worktree = WorktreeState.get(sessionDirectory) + if (!worktree || worktree.status !== "pending") return true + + if (sessionDirectory === projectDirectory) { + sync.set("session_status", session.id, { type: "busy" }) + } + + const controller = new AbortController() + const cleanup = () => { + if (sessionDirectory === projectDirectory) { + sync.set("session_status", session.id, { type: "idle" }) + } + removeOptimisticMessage() + restoreCommentItems(commentItems) + restoreInput() + } + + pending.set(session.id, { abort: controller, cleanup }) + + const abortWait = new Promise>>((resolve) => { + if (controller.signal.aborted) { + resolve({ status: "failed", message: "aborted" }) + return + } + controller.signal.addEventListener( + "abort", + () => { + resolve({ status: "failed", message: "aborted" }) + }, + { once: true }, + ) + }) + + const timeoutMs = 5 * 60 * 1000 + const timer = { id: undefined as number | undefined } + const timeout = new Promise>>((resolve) => { + timer.id = window.setTimeout(() => { + resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") }) + }, timeoutMs) + }) + + const result = await Promise.race([WorktreeState.wait(sessionDirectory), abortWait, timeout]).finally(() => { + if (timer.id === undefined) return + clearTimeout(timer.id) + }) + pending.delete(session.id) + if (controller.signal.aborted) return false + if (result.status === "failed") throw new Error(result.message) + return true + } + + const send = async () => { + const ok = await waitForWorktree() + if (!ok) return + await client.session.prompt({ + sessionID: session.id, + agent, + model, + messageID, + parts: requestParts, + variant, + }) + } + + void send().catch((err) => { + pending.delete(session.id) + if (sessionDirectory === projectDirectory) { + sync.set("session_status", session.id, { type: "idle" }) + } + showToast({ + title: language.t("prompt.toast.promptSendFailed.title"), + description: errorMessage(err), + }) + removeOptimisticMessage() + restoreCommentItems(commentItems) + restoreInput() + }) + } + + return { + abort, + handleSubmit, + } +} diff --git a/packages/app/src/components/server/server-row.tsx b/packages/app/src/components/server/server-row.tsx new file mode 100644 index 0000000000..b43c07882c --- /dev/null +++ b/packages/app/src/components/server/server-row.tsx @@ -0,0 +1,77 @@ +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { JSXElement, ParentProps, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js" +import { serverDisplayName } from "@/context/server" +import type { ServerHealth } from "@/utils/server-health" + +interface ServerRowProps extends ParentProps { + url: string + status?: ServerHealth + class?: string + nameClass?: string + versionClass?: string + dimmed?: boolean + badge?: JSXElement +} + +export function ServerRow(props: ServerRowProps) { + const [truncated, setTruncated] = createSignal(false) + let nameRef: HTMLSpanElement | undefined + let versionRef: HTMLSpanElement | undefined + + const check = () => { + const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false + const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false + setTruncated(nameTruncated || versionTruncated) + } + + createEffect(() => { + props.url + props.status?.version + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(check) + return + } + check() + }) + + onMount(() => { + check() + if (typeof window === "undefined") return + window.addEventListener("resize", check) + onCleanup(() => window.removeEventListener("resize", check)) + }) + + const tooltipValue = () => ( + + {serverDisplayName(props.url)} + + {props.status?.version} + + + ) + + return ( + +
+
+ + {serverDisplayName(props.url)} + + + + {props.status?.version} + + + {props.badge} + {props.children} +
+ + ) +} diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index c6256395fc..4e5dae139c 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -3,12 +3,11 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { Button } from "@opencode-ai/ui/button" import { useParams } from "@solidjs/router" -import { AssistantMessage } from "@opencode-ai/sdk/v2/client" -import { findLast } from "@opencode-ai/util/array" import { useLayout } from "@/context/layout" import { useSync } from "@/context/sync" import { useLanguage } from "@/context/language" +import { getSessionContextMetrics } from "@/components/session/session-context-metrics" interface SessionContextUsageProps { variant?: "button" | "indicator" @@ -34,26 +33,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) { }), ) + const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all)) + const context = createMemo(() => metrics().context) const cost = createMemo(() => { - const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0) - return usd().format(total) - }) - - const context = createMemo(() => { - const locale = language.locale() - const last = findLast(messages(), (x) => { - if (x.role !== "assistant") return false - const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write - return total > 0 - }) as AssistantMessage - if (!last) return - const total = - last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write - const model = sync.data.provider.all.find((x) => x.id === last.providerID)?.models[last.modelID] - return { - tokens: total.toLocaleString(locale), - percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null, - } + return usd().format(metrics().totalCost) }) const openContext = () => { @@ -67,7 +50,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const circle = () => (
- +
) @@ -77,11 +60,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) { {(ctx) => ( <>
- {ctx().tokens} + {ctx().total.toLocaleString(language.locale())} {language.t("context.usage.tokens")}
- {ctx().percentage ?? 0}% + {ctx().usage ?? 0}% {language.t("context.usage.usage")}
diff --git a/packages/app/src/components/session/session-context-metrics.test.ts b/packages/app/src/components/session/session-context-metrics.test.ts new file mode 100644 index 0000000000..68903a455b --- /dev/null +++ b/packages/app/src/components/session/session-context-metrics.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test" +import type { Message } from "@opencode-ai/sdk/v2/client" +import { getSessionContextMetrics } from "./session-context-metrics" + +const assistant = ( + id: string, + tokens: { input: number; output: number; reasoning: number; read: number; write: number }, + cost: number, + providerID = "openai", + modelID = "gpt-4.1", +) => { + return { + id, + role: "assistant", + providerID, + modelID, + cost, + tokens: { + input: tokens.input, + output: tokens.output, + reasoning: tokens.reasoning, + cache: { + read: tokens.read, + write: tokens.write, + }, + }, + time: { created: 1 }, + } as unknown as Message +} + +const user = (id: string) => { + return { + id, + role: "user", + cost: 0, + time: { created: 1 }, + } as unknown as Message +} + +describe("getSessionContextMetrics", () => { + test("computes totals and usage from latest assistant with tokens", () => { + const messages = [ + user("u1"), + assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5), + assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25), + ] + const providers = [ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-4.1": { + name: "GPT-4.1", + limit: { context: 1000 }, + }, + }, + }, + ] + + const metrics = getSessionContextMetrics(messages, providers) + + expect(metrics.totalCost).toBe(1.75) + expect(metrics.context?.message.id).toBe("a2") + expect(metrics.context?.total).toBe(500) + expect(metrics.context?.usage).toBe(50) + expect(metrics.context?.providerLabel).toBe("OpenAI") + expect(metrics.context?.modelLabel).toBe("GPT-4.1") + }) + + test("preserves fallback labels and null usage when model metadata is missing", () => { + const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")] + const providers = [{ id: "p-1", models: {} }] + + const metrics = getSessionContextMetrics(messages, providers) + + expect(metrics.context?.providerLabel).toBe("p-1") + expect(metrics.context?.modelLabel).toBe("m-1") + expect(metrics.context?.limit).toBeUndefined() + expect(metrics.context?.usage).toBeNull() + }) + + test("memoizes by message and provider array identity", () => { + const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)] + const providers = [{ id: "openai", models: {} }] + + const one = getSessionContextMetrics(messages, providers) + const two = getSessionContextMetrics(messages, providers) + const three = getSessionContextMetrics([...messages], providers) + + expect(two).toBe(one) + expect(three).not.toBe(one) + }) +}) diff --git a/packages/app/src/components/session/session-context-metrics.ts b/packages/app/src/components/session/session-context-metrics.ts new file mode 100644 index 0000000000..2b6edbd951 --- /dev/null +++ b/packages/app/src/components/session/session-context-metrics.ts @@ -0,0 +1,94 @@ +import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client" + +type Provider = { + id: string + name?: string + models: Record +} + +type Model = { + name?: string + limit: { + context: number + } +} + +type Context = { + message: AssistantMessage + provider?: Provider + model?: Model + providerLabel: string + modelLabel: string + limit: number | undefined + input: number + output: number + reasoning: number + cacheRead: number + cacheWrite: number + total: number + usage: number | null +} + +type Metrics = { + totalCost: number + context: Context | undefined +} + +const cache = new WeakMap>() + +const tokenTotal = (msg: AssistantMessage) => { + return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write +} + +const lastAssistantWithTokens = (messages: Message[]) => { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.role !== "assistant") continue + if (tokenTotal(msg) <= 0) continue + return msg + } +} + +const build = (messages: Message[], providers: Provider[]): Metrics => { + const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0) + const message = lastAssistantWithTokens(messages) + if (!message) return { totalCost, context: undefined } + + const provider = providers.find((item) => item.id === message.providerID) + const model = provider?.models[message.modelID] + const limit = model?.limit.context + const total = tokenTotal(message) + + return { + totalCost, + context: { + message, + provider, + model, + providerLabel: provider?.name ?? message.providerID, + modelLabel: model?.name ?? message.modelID, + limit, + input: message.tokens.input, + output: message.tokens.output, + reasoning: message.tokens.reasoning, + cacheRead: message.tokens.cache.read, + cacheWrite: message.tokens.cache.write, + total, + usage: limit ? Math.round((total / limit) * 100) : null, + }, + } +} + +export function getSessionContextMetrics(messages: Message[], providers: Provider[]) { + const byProvider = cache.get(messages) + if (byProvider) { + const hit = byProvider.get(providers) + if (hit) return hit + } + + const value = build(messages, providers) + const next = byProvider ?? new WeakMap() + next.set(providers, value) + if (!byProvider) cache.set(messages, next) + return value +} diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 37733caff6..8aae44863e 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -11,8 +11,9 @@ import { Accordion } from "@opencode-ai/ui/accordion" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { Code } from "@opencode-ai/ui/code" import { Markdown } from "@opencode-ai/ui/markdown" -import type { AssistantMessage, Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" +import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" import { useLanguage } from "@/context/language" +import { getSessionContextMetrics } from "./session-context-metrics" interface SessionContextTabProps { messages: () => Message[] @@ -34,44 +35,11 @@ export function SessionContextTab(props: SessionContextTabProps) { }), ) - const ctx = createMemo(() => { - const last = findLast(props.messages(), (x) => { - if (x.role !== "assistant") return false - const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write - return total > 0 - }) as AssistantMessage - if (!last) return - - const provider = sync.data.provider.all.find((x) => x.id === last.providerID) - const model = provider?.models[last.modelID] - const limit = model?.limit.context - - const input = last.tokens.input - const output = last.tokens.output - const reasoning = last.tokens.reasoning - const cacheRead = last.tokens.cache.read - const cacheWrite = last.tokens.cache.write - const total = input + output + reasoning + cacheRead + cacheWrite - const usage = limit ? Math.round((total / limit) * 100) : null - - return { - message: last, - provider, - model, - limit, - input, - output, - reasoning, - cacheRead, - cacheWrite, - total, - usage, - } - }) + const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all)) + const ctx = createMemo(() => metrics().context) const cost = createMemo(() => { - const total = props.messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0) - return usd().format(total) + return usd().format(metrics().totalCost) }) const counts = createMemo(() => { @@ -114,14 +82,13 @@ export function SessionContextTab(props: SessionContextTabProps) { const providerLabel = createMemo(() => { const c = ctx() if (!c) return "—" - return c.provider?.name ?? c.message.providerID + return c.providerLabel }) const modelLabel = createMemo(() => { const c = ctx() if (!c) return "—" - if (c.model?.name) return c.model.name - return c.message.modelID + return c.modelLabel }) const breakdown = createMemo( diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 43057d63b9..805e699312 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -67,9 +67,39 @@ export function SessionHeader() { "xcode", "android-studio", "powershell", + "sublime-text", ] as const type OpenApp = (typeof OPEN_APPS)[number] + const MAC_APPS = [ + { id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" }, + { id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" }, + { id: "zed", label: "Zed", icon: "zed", openWith: "Zed" }, + { id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" }, + { id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" }, + { id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" }, + { id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" }, + { id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" }, + { id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" }, + { id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" }, + { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" }, + ] as const + + const WINDOWS_APPS = [ + { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, + { id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" }, + { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" }, + ] as const + + const LINUX_APPS = [ + { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, + { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" }, + ] as const + const os = createMemo<"macos" | "windows" | "linux" | "unknown">(() => { if (platform.platform === "desktop" && platform.os) return platform.os if (typeof navigator !== "object") return "unknown" @@ -80,38 +110,44 @@ export function SessionHeader() { return "unknown" }) + const [exists, setExists] = createStore>>({ finder: true }) + + createEffect(() => { + if (platform.platform !== "desktop") return + if (!platform.checkAppExists) return + + const list = os() + const apps = list === "macos" ? MAC_APPS : list === "windows" ? WINDOWS_APPS : list === "linux" ? LINUX_APPS : [] + if (apps.length === 0) return + + void Promise.all( + apps.map((app) => + Promise.resolve(platform.checkAppExists?.(app.openWith)).then((value) => { + const ok = Boolean(value) + console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`) + return [app.id, ok] as const + }), + ), + ).then((entries) => { + setExists(Object.fromEntries(entries) as Partial>) + }) + }) + const options = createMemo(() => { if (os() === "macos") { - return [ - { id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" }, - { id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" }, - { id: "zed", label: "Zed", icon: "zed", openWith: "Zed" }, - { id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" }, - { id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" }, - { id: "finder", label: "Finder", icon: "finder" }, - { id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" }, - { id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" }, - { id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" }, - { id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" }, - { id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" }, - ] as const + return [{ id: "finder", label: "Finder", icon: "finder" }, ...MAC_APPS.filter((app) => exists[app.id])] as const } if (os() === "windows") { return [ - { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, - { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, - { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, - { id: "finder", label: "File Explorer", icon: "finder" }, - { id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" }, + { id: "finder", label: "File Explorer", icon: "file-explorer" }, + ...WINDOWS_APPS.filter((app) => exists[app.id]), ] as const } return [ - { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, - { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, - { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, { id: "finder", label: "File Manager", icon: "finder" }, + ...LINUX_APPS.filter((app) => exists[app.id]), ] as const }) @@ -268,74 +304,78 @@ export function SessionHeader() {
- - - {language.t("session.header.open.copyPath")} - - } - > -
- - - -
-
+ class="rounded-sm h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none" + onClick={copyPath} + aria-label={language.t("session.header.open.copyPath")} + > + + + {language.t("session.header.open.copyPath")} + + + } + > +
+ + + + + + + {language.t("session.header.openIn")} + { + if (!OPEN_APPS.includes(value as OpenApp)) return + setPrefs("app", value as OpenApp) + }} + > + {options().map((o) => ( + openDir(o.id)}> + + {o.label} + + + + + ))} + + + + + + + {language.t("session.header.open.copyPath")} + + + + + +
+
+
diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 102c477a10..3354c3d362 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" +import { createEffect, createMemo, For, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { useNavigate } from "@solidjs/router" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -7,30 +7,15 @@ import { Tabs } from "@opencode-ai/ui/tabs" import { Button } from "@opencode-ai/ui/button" import { Switch } from "@opencode-ai/ui/switch" import { Icon } from "@opencode-ai/ui/icon" -import { Tooltip } from "@opencode-ai/ui/tooltip" import { useSync } from "@/context/sync" import { useSDK } from "@/context/sdk" -import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server" +import { normalizeServerUrl, useServer } from "@/context/server" import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language" -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { DialogSelectServer } from "./dialog-select-server" import { showToast } from "@opencode-ai/ui/toast" - -type ServerStatus = { healthy: boolean; version?: string } - -async function checkHealth(url: string, platform: ReturnType): Promise { - const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000) - const sdk = createOpencodeClient({ - baseUrl: url, - fetch: platform.fetch, - signal, - }) - return sdk.global - .health() - .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version })) - .catch(() => ({ healthy: false })) -} +import { ServerRow } from "@/components/server/server-row" +import { checkServerHealth, type ServerHealth } from "@/utils/server-health" export function StatusPopover() { const sync = useSync() @@ -42,10 +27,11 @@ export function StatusPopover() { const navigate = useNavigate() const [store, setStore] = createStore({ - status: {} as Record, + status: {} as Record, loading: null as string | null, defaultServerUrl: undefined as string | undefined, }) + const fetcher = platform.fetch ?? globalThis.fetch const servers = createMemo(() => { const current = server.url @@ -60,7 +46,7 @@ export function StatusPopover() { if (!list.length) return list const active = server.url const order = new Map(list.map((url, index) => [url, index] as const)) - const rank = (value?: ServerStatus) => { + const rank = (value?: ServerHealth) => { if (value?.healthy === true) return 0 if (value?.healthy === false) return 2 return 1 @@ -75,10 +61,10 @@ export function StatusPopover() { }) async function refreshHealth() { - const results: Record = {} + const results: Record = {} await Promise.all( servers().map(async (url) => { - results[url] = await checkHealth(url, platform) + results[url] = await checkServerHealth(url, fetcher) }), ) setStore("status", reconcile(results)) @@ -213,78 +199,43 @@ export function StatusPopover() { const isDefault = () => url === store.defaultServerUrl const status = () => store.status[url] const isBlocked = () => status()?.healthy === false - const [truncated, setTruncated] = createSignal(false) - let nameRef: HTMLSpanElement | undefined - let versionRef: HTMLSpanElement | undefined - - onMount(() => { - const check = () => { - const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false - const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false - setTruncated(nameTruncated || versionTruncated) - } - check() - window.addEventListener("resize", check) - onCleanup(() => window.removeEventListener("resize", check)) - }) - - const tooltipValue = () => { - const name = serverDisplayName(url) - const version = status()?.version - return ( - - {name} - - {version} - - - ) - } return ( - - - + + ) }} diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 4d44d5f7e9..64adc797c9 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -8,6 +8,7 @@ import { LocalPTY } from "@/context/terminal" import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme" import { useLanguage } from "@/context/language" import { showToast } from "@opencode-ai/ui/toast" +import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" export interface TerminalProps extends ComponentProps<"div"> { pty: LocalPTY @@ -111,17 +112,13 @@ export const Terminal = (props: TerminalProps) => { const colors = getTerminalColors() setTerminalColors(colors) if (!term) return - const setOption = (term as unknown as { setOption?: (key: string, value: TerminalColors) => void }).setOption - if (!setOption) return - setOption("theme", colors) + setOptionIfSupported(term, "theme", colors) }) createEffect(() => { const font = monoFontFamily(settings.appearance.font()) if (!term) return - const setOption = (term as unknown as { setOption?: (key: string, value: string) => void }).setOption - if (!setOption) return - setOption("fontFamily", font) + setOptionIfSupported(term, "fontFamily", font) }) const focusTerminal = () => { @@ -146,12 +143,12 @@ export const Terminal = (props: TerminalProps) => { const t = term if (!t) return - const link = (t as unknown as { currentHoveredLink?: { text: string } }).currentHoveredLink - if (!link?.text) return + const text = getHoveredLinkText(t) + if (!text) return event.preventDefault() event.stopImmediatePropagation() - platform.openLink(link.text) + platform.openLink(text) } onMount(() => { @@ -250,7 +247,7 @@ export const Terminal = (props: TerminalProps) => { const fit = new mod.FitAddon() const serializer = new SerializeAddon() - cleanups.push(() => (fit as unknown as { dispose?: VoidFunction }).dispose?.()) + cleanups.push(() => disposeIfDisposable(fit)) t.loadAddon(serializer) t.loadAddon(fit) fitAddon = fit @@ -290,6 +287,27 @@ export const Terminal = (props: TerminalProps) => { handleResize = () => fit.fit() window.addEventListener("resize", handleResize) cleanups.push(() => window.removeEventListener("resize", handleResize)) + const limit = 16_384 + const min = 32 + const windowMs = 750 + const seed = tail.length > limit ? tail.slice(-limit) : tail + let sync = seed.length >= min + let syncUntil = 0 + const stopSync = () => { + sync = false + syncUntil = 0 + } + + const overlap = (data: string) => { + if (!seed) return 0 + const max = Math.min(seed.length, data.length) + if (max < min) return 0 + for (let i = max; i >= min; i--) { + if (seed.slice(-i) === data.slice(0, i)) return i + } + return 0 + } + const onResize = t.onResize(async (size) => { if (socket.readyState === WebSocket.OPEN) { await sdk.client.pty @@ -303,38 +321,27 @@ export const Terminal = (props: TerminalProps) => { .catch(() => {}) } }) - cleanups.push(() => (onResize as unknown as { dispose?: VoidFunction }).dispose?.()) + cleanups.push(() => disposeIfDisposable(onResize)) const onData = t.onData((data) => { + if (data) stopSync() if (socket.readyState === WebSocket.OPEN) { socket.send(data) } }) - cleanups.push(() => (onData as unknown as { dispose?: VoidFunction }).dispose?.()) + cleanups.push(() => disposeIfDisposable(onData)) const onKey = t.onKey((key) => { if (key.key == "Enter") { props.onSubmit?.() } }) - cleanups.push(() => (onKey as unknown as { dispose?: VoidFunction }).dispose?.()) + cleanups.push(() => disposeIfDisposable(onKey)) // t.onScroll((ydisp) => { // console.log("Scroll position:", ydisp) // }) - const limit = 16_384 - const seed = tail - let sync = !!seed - - const overlap = (data: string) => { - if (!seed) return 0 - const max = Math.min(seed.length, data.length) - for (let i = max; i > 0; i--) { - if (seed.slice(-i) === data.slice(0, i)) return i - } - return 0 - } - const handleOpen = () => { local.onConnect?.() + if (sync) syncUntil = Date.now() + windowMs sdk.client.pty .update({ ptyID: local.pty.id, @@ -349,18 +356,23 @@ export const Terminal = (props: TerminalProps) => { cleanups.push(() => socket.removeEventListener("open", handleOpen)) const handleMessage = (event: MessageEvent) => { + if (disposed) return const data = typeof event.data === "string" ? event.data : "" if (!data) return const next = (() => { if (!sync) return data + if (syncUntil && Date.now() > syncUntil) { + stopSync() + return data + } const n = overlap(data) if (!n) { - sync = false + stopSync() return data } const trimmed = data.slice(n) - if (trimmed) sync = false + if (trimmed) stopSync() return trimmed })() diff --git a/packages/app/src/components/titlebar-history.test.ts b/packages/app/src/components/titlebar-history.test.ts new file mode 100644 index 0000000000..25035d7ccf --- /dev/null +++ b/packages/app/src/components/titlebar-history.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test" +import { applyPath, backPath, forwardPath, type TitlebarHistory } from "./titlebar-history" + +function history(): TitlebarHistory { + return { stack: [], index: 0, action: undefined } +} + +describe("titlebar history", () => { + test("append and trim keeps max bounded", () => { + let state = history() + state = applyPath(state, "/", 3) + state = applyPath(state, "/a", 3) + state = applyPath(state, "/b", 3) + state = applyPath(state, "/c", 3) + + expect(state.stack).toEqual(["/a", "/b", "/c"]) + expect(state.stack.length).toBe(3) + expect(state.index).toBe(2) + }) + + test("back and forward indexes stay correct after trimming", () => { + let state = history() + state = applyPath(state, "/", 3) + state = applyPath(state, "/a", 3) + state = applyPath(state, "/b", 3) + state = applyPath(state, "/c", 3) + + expect(state.stack).toEqual(["/a", "/b", "/c"]) + expect(state.index).toBe(2) + + const back = backPath(state) + expect(back?.to).toBe("/b") + expect(back?.state.index).toBe(1) + + const afterBack = applyPath(back!.state, back!.to, 3) + expect(afterBack.stack).toEqual(["/a", "/b", "/c"]) + expect(afterBack.index).toBe(1) + + const forward = forwardPath(afterBack) + expect(forward?.to).toBe("/c") + expect(forward?.state.index).toBe(2) + + const afterForward = applyPath(forward!.state, forward!.to, 3) + expect(afterForward.stack).toEqual(["/a", "/b", "/c"]) + expect(afterForward.index).toBe(2) + }) + + test("action-driven navigation does not push duplicate history entries", () => { + const state: TitlebarHistory = { + stack: ["/", "/a", "/b"], + index: 2, + action: undefined, + } + + const back = backPath(state) + expect(back?.to).toBe("/a") + + const next = applyPath(back!.state, back!.to, 10) + expect(next.stack).toEqual(["/", "/a", "/b"]) + expect(next.index).toBe(1) + expect(next.action).toBeUndefined() + }) +}) diff --git a/packages/app/src/components/titlebar-history.ts b/packages/app/src/components/titlebar-history.ts new file mode 100644 index 0000000000..44dbbfa3a4 --- /dev/null +++ b/packages/app/src/components/titlebar-history.ts @@ -0,0 +1,57 @@ +export const MAX_TITLEBAR_HISTORY = 100 + +export type TitlebarAction = "back" | "forward" | undefined + +export type TitlebarHistory = { + stack: string[] + index: number + action: TitlebarAction +} + +export function applyPath(state: TitlebarHistory, current: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory { + if (!state.stack.length) { + const stack = current === "/" ? ["/"] : ["/", current] + return { stack, index: stack.length - 1, action: undefined } + } + + const active = state.stack[state.index] + if (current === active) { + if (!state.action) return state + return { ...state, action: undefined } + } + + if (state.action) return { ...state, action: undefined } + + return pushPath(state, current, max) +} + +export function pushPath(state: TitlebarHistory, path: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory { + const stack = state.stack.slice(0, state.index + 1).concat(path) + const next = trimHistory(stack, stack.length - 1, max) + return { ...state, ...next, action: undefined } +} + +export function trimHistory(stack: string[], index: number, max = MAX_TITLEBAR_HISTORY) { + if (stack.length <= max) return { stack, index } + const cut = stack.length - max + return { + stack: stack.slice(cut), + index: Math.max(0, index - cut), + } +} + +export function backPath(state: TitlebarHistory) { + if (state.index <= 0) return + const index = state.index - 1 + const to = state.stack[index] + if (!to) return + return { state: { ...state, index, action: "back" as const }, to } +} + +export function forwardPath(state: TitlebarHistory) { + if (state.index >= state.stack.length - 1) return + const index = state.index + 1 + const to = state.stack[index] + if (!to) return + return { state: { ...state, index, action: "forward" as const }, to } +} diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 86b4fbeb1b..4a43a855ce 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -11,6 +11,7 @@ import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" +import { applyPath, backPath, forwardPath } from "./titlebar-history" export function Titlebar() { const layout = useLayout() @@ -39,25 +40,9 @@ export function Titlebar() { const current = path() untrack(() => { - if (!history.stack.length) { - const stack = current === "/" ? ["/"] : ["/", current] - setHistory({ stack, index: stack.length - 1 }) - return - } - - const active = history.stack[history.index] - if (current === active) { - if (history.action) setHistory("action", undefined) - return - } - - if (history.action) { - setHistory("action", undefined) - return - } - - const next = history.stack.slice(0, history.index + 1).concat(current) - setHistory({ stack: next, index: next.length - 1 }) + const next = applyPath(history, current) + if (next === history) return + setHistory(next) }) }) @@ -65,29 +50,47 @@ export function Titlebar() { const canForward = createMemo(() => history.index < history.stack.length - 1) const back = () => { - if (!canBack()) return - const index = history.index - 1 - const to = history.stack[index] - if (!to) return - setHistory({ index, action: "back" }) - navigate(to) + const next = backPath(history) + if (!next) return + setHistory(next.state) + navigate(next.to) } const forward = () => { - if (!canForward()) return - const index = history.index + 1 - const to = history.stack[index] - if (!to) return - setHistory({ index, action: "forward" }) - navigate(to) + const next = forwardPath(history) + if (!next) return + setHistory(next.state) + navigate(next.to) } + command.register(() => [ + { + id: "common.goBack", + title: language.t("common.goBack"), + category: language.t("command.category.view"), + onSelect: back, + }, + { + id: "common.goForward", + title: language.t("common.goForward"), + category: language.t("command.category.view"), + onSelect: forward, + }, + ]) + const getWin = () => { if (platform.platform !== "desktop") return const tauri = ( window as unknown as { - __TAURI__?: { window?: { getCurrentWindow?: () => { startDragging?: () => Promise } } } + __TAURI__?: { + window?: { + getCurrentWindow?: () => { + startDragging?: () => Promise + toggleMaximize?: () => Promise + } + } + } } ).__TAURI__ if (!tauri?.window?.getCurrentWindow) return @@ -133,17 +136,30 @@ export function Titlebar() { void win.startDragging().catch(() => undefined) } + const maximize = (e: MouseEvent) => { + if (platform.platform !== "desktop") return + if (interactive(e.target)) return + if (e.target instanceof Element && e.target.closest("[data-tauri-decorum-tb]")) return + + const win = getWin() + if (!win?.toggleMaximize) return + + e.preventDefault() + void win.toggleMaximize().catch(() => undefined) + } + return (
diff --git a/packages/app/src/context/command-keybind.test.ts b/packages/app/src/context/command-keybind.test.ts new file mode 100644 index 0000000000..4e38efd8da --- /dev/null +++ b/packages/app/src/context/command-keybind.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" +import { formatKeybind, matchKeybind, parseKeybind } from "./command" + +describe("command keybind helpers", () => { + test("parseKeybind handles aliases and multiple combos", () => { + const keybinds = parseKeybind("control+option+k, mod+shift+comma") + + expect(keybinds).toHaveLength(2) + expect(keybinds[0]).toEqual({ + key: "k", + ctrl: true, + meta: false, + shift: false, + alt: true, + }) + expect(keybinds[1]?.shift).toBe(true) + expect(keybinds[1]?.key).toBe("comma") + expect(Boolean(keybinds[1]?.ctrl || keybinds[1]?.meta)).toBe(true) + }) + + test("parseKeybind treats none and empty as disabled", () => { + expect(parseKeybind("none")).toEqual([]) + expect(parseKeybind("")).toEqual([]) + }) + + test("matchKeybind normalizes punctuation keys", () => { + const keybinds = parseKeybind("ctrl+comma, shift+plus, meta+space") + + expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true }))).toBe(true) + expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: "+", shiftKey: true }))).toBe(true) + expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: " ", metaKey: true }))).toBe(true) + expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false) + }) + + test("formatKeybind returns human readable output", () => { + const display = formatKeybind("ctrl+alt+arrowup") + + expect(display).toContain("↑") + expect(display.includes("Ctrl") || display.includes("⌃")).toBe(true) + expect(display.includes("Alt") || display.includes("⌥")).toBe(true) + expect(formatKeybind("none")).toBe("") + }) +}) diff --git a/packages/app/src/context/command.test.ts b/packages/app/src/context/command.test.ts new file mode 100644 index 0000000000..2b956287c5 --- /dev/null +++ b/packages/app/src/context/command.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test" +import { upsertCommandRegistration } from "./command" + +describe("upsertCommandRegistration", () => { + test("replaces keyed registrations", () => { + const one = () => [{ id: "one", title: "One" }] + const two = () => [{ id: "two", title: "Two" }] + + const next = upsertCommandRegistration([{ key: "layout", options: one }], { key: "layout", options: two }) + + expect(next).toHaveLength(1) + expect(next[0]?.options).toBe(two) + }) + + test("keeps unkeyed registrations additive", () => { + const one = () => [{ id: "one", title: "One" }] + const two = () => [{ id: "two", title: "Two" }] + + const next = upsertCommandRegistration([{ options: one }], { options: two }) + + expect(next).toHaveLength(2) + expect(next[0]?.options).toBe(two) + expect(next[1]?.options).toBe(one) + }) +}) diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index 7915695840..e6a16fd4bb 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -64,6 +64,16 @@ export type CommandCatalogItem = { slash?: string } +export type CommandRegistration = { + key?: string + options: Accessor +} + +export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) { + if (entry.key === undefined) return [entry, ...registrations] + return [entry, ...registrations.filter((x) => x.key !== entry.key)] +} + export function parseKeybind(config: string): Keybind[] { if (!config || config === "none") return [] @@ -166,9 +176,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const settings = useSettings() const language = useLanguage() const [store, setStore] = createStore({ - registrations: [] as Accessor[], + registrations: [] as CommandRegistration[], suspendCount: 0, }) + const warnedDuplicates = new Set() const [catalog, setCatalog, _, catalogReady] = persisted( Persist.global("command.catalog.v1"), @@ -187,8 +198,14 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const all: CommandOption[] = [] for (const reg of store.registrations) { - for (const opt of reg()) { - if (seen.has(opt.id)) continue + for (const opt of reg.options()) { + if (seen.has(opt.id)) { + if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) { + warnedDuplicates.add(opt.id) + console.warn(`[command] duplicate command id \"${opt.id}\" registered; keeping first entry`) + } + continue + } seen.add(opt.id) all.push(opt) } @@ -296,14 +313,25 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex document.removeEventListener("keydown", handleKeyDown) }) + function register(cb: () => CommandOption[]): void + function register(key: string, cb: () => CommandOption[]): void + function register(key: string | (() => CommandOption[]), cb?: () => CommandOption[]) { + const id = typeof key === "string" ? key : undefined + const next = typeof key === "function" ? key : cb + if (!next) return + const options = createMemo(next) + const entry: CommandRegistration = { + key: id, + options, + } + setStore("registrations", (arr) => upsertCommandRegistration(arr, entry)) + onCleanup(() => { + setStore("registrations", (arr) => arr.filter((x) => x !== entry)) + }) + } + return { - register(cb: () => CommandOption[]) { - const results = createMemo(cb) - setStore("registrations", (arr) => [results, ...arr]) - onCleanup(() => { - setStore("registrations", (arr) => arr.filter((x) => x !== results)) - }) - }, + register, trigger(id: string, source?: "palette" | "keybind" | "slash") { run(id, source) }, diff --git a/packages/app/src/context/comments.test.ts b/packages/app/src/context/comments.test.ts new file mode 100644 index 0000000000..13cb132c4d --- /dev/null +++ b/packages/app/src/context/comments.test.ts @@ -0,0 +1,111 @@ +import { beforeAll, describe, expect, mock, test } from "bun:test" +import { createRoot } from "solid-js" +import type { LineComment } from "./comments" + +let createCommentSessionForTest: typeof import("./comments").createCommentSessionForTest + +beforeAll(async () => { + mock.module("@solidjs/router", () => ({ + useParams: () => ({}), + })) + mock.module("@opencode-ai/ui/context", () => ({ + createSimpleContext: () => ({ + use: () => undefined, + provider: () => undefined, + }), + })) + const mod = await import("./comments") + createCommentSessionForTest = mod.createCommentSessionForTest +}) + +function line(file: string, id: string, time: number): LineComment { + return { + id, + file, + comment: id, + time, + selection: { start: 1, end: 1 }, + } +} + +describe("comments session indexing", () => { + test("keeps file list behavior and aggregate chronological order", () => { + createRoot((dispose) => { + const now = Date.now() + const comments = createCommentSessionForTest({ + "a.ts": [line("a.ts", "a-late", now + 20_000), line("a.ts", "a-early", now + 1_000)], + "b.ts": [line("b.ts", "b-mid", now + 10_000)], + }) + + expect(comments.list("a.ts").map((item) => item.id)).toEqual(["a-late", "a-early"]) + expect(comments.all().map((item) => item.id)).toEqual(["a-early", "b-mid", "a-late"]) + + const next = comments.add({ + file: "b.ts", + comment: "next", + selection: { start: 2, end: 2 }, + }) + + expect(comments.list("b.ts").at(-1)?.id).toBe(next.id) + expect(comments.all().map((item) => item.time)).toEqual( + comments + .all() + .map((item) => item.time) + .slice() + .sort((a, b) => a - b), + ) + + dispose() + }) + }) + + test("remove updates file and aggregate indexes consistently", () => { + createRoot((dispose) => { + const comments = createCommentSessionForTest({ + "a.ts": [line("a.ts", "a1", 10), line("a.ts", "shared", 20)], + "b.ts": [line("b.ts", "shared", 30)], + }) + + comments.setFocus({ file: "a.ts", id: "shared" }) + comments.setActive({ file: "a.ts", id: "shared" }) + comments.remove("a.ts", "shared") + + expect(comments.list("a.ts").map((item) => item.id)).toEqual(["a1"]) + expect( + comments + .all() + .filter((item) => item.id === "shared") + .map((item) => item.file), + ).toEqual(["b.ts"]) + expect(comments.focus()).toBeNull() + expect(comments.active()).toEqual({ file: "a.ts", id: "shared" }) + + dispose() + }) + }) + + test("clear resets file and aggregate indexes plus focus state", () => { + createRoot((dispose) => { + const comments = createCommentSessionForTest({ + "a.ts": [line("a.ts", "a1", 10)], + }) + + const next = comments.add({ + file: "b.ts", + comment: "next", + selection: { start: 2, end: 2 }, + }) + + comments.setActive({ file: "b.ts", id: next.id }) + comments.clear() + + expect(comments.list("a.ts")).toEqual([]) + expect(comments.list("b.ts")).toEqual([]) + expect(comments.all()).toEqual([]) + expect(comments.focus()).toBeNull() + expect(comments.active()).toBeNull() + + dispose() + }) + }) +}) diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx index d51c163524..d43f3705be 100644 --- a/packages/app/src/context/comments.tsx +++ b/packages/app/src/context/comments.tsx @@ -1,8 +1,9 @@ -import { batch, createMemo, createRoot, onCleanup } from "solid-js" -import { createStore } from "solid-js/store" +import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js" +import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { useParams } from "@solidjs/router" import { Persist, persisted } from "@/utils/persist" +import { createScopedCache } from "@/utils/scoped-cache" import type { SelectedLineRange } from "@/context/file" export type LineComment = { @@ -18,28 +19,28 @@ type CommentFocus = { file: string; id: string } const WORKSPACE_KEY = "__workspace__" const MAX_COMMENT_SESSIONS = 20 -type CommentSession = ReturnType - -type CommentCacheEntry = { - value: CommentSession - dispose: VoidFunction +type CommentStore = { + comments: Record } -function createCommentSession(dir: string, id: string | undefined) { - const legacy = `${dir}/comments${id ? "/" + id : ""}.v1` +function aggregate(comments: Record) { + return Object.keys(comments) + .flatMap((file) => comments[file] ?? []) + .slice() + .sort((a, b) => a.time - b.time) +} - const [store, setStore, _, ready] = persisted( - Persist.scoped(dir, id, "comments", [legacy]), - createStore<{ - comments: Record - }>({ - comments: {}, - }), - ) +function insert(items: LineComment[], next: LineComment) { + const index = items.findIndex((item) => item.time > next.time) + if (index < 0) return [...items, next] + return [...items.slice(0, index), next, ...items.slice(index)] +} +function createCommentSessionState(store: Store, setStore: SetStoreFunction) { const [state, setState] = createStore({ focus: null as CommentFocus | null, active: null as CommentFocus | null, + all: aggregate(store.comments), }) const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) => @@ -59,6 +60,7 @@ function createCommentSession(dir: string, id: string | undefined) { batch(() => { setStore("comments", input.file, (items) => [...(items ?? []), next]) + setState("all", (items) => insert(items, next)) setFocus({ file: input.file, id: next.id }) }) @@ -66,37 +68,72 @@ function createCommentSession(dir: string, id: string | undefined) { } const remove = (file: string, id: string) => { - setStore("comments", file, (items) => (items ?? []).filter((x) => x.id !== id)) - setFocus((current) => (current?.id === id ? null : current)) + batch(() => { + setStore("comments", file, (items) => (items ?? []).filter((item) => item.id !== id)) + setState("all", (items) => items.filter((item) => !(item.file === file && item.id === id))) + setFocus((current) => (current?.id === id ? null : current)) + }) } const clear = () => { batch(() => { - setStore("comments", {}) + setStore("comments", reconcile({})) + setState("all", []) setFocus(null) setActive(null) }) } - const all = createMemo(() => { - const files = Object.keys(store.comments) - const items = files.flatMap((file) => store.comments[file] ?? []) - return items.slice().sort((a, b) => a.time - b.time) + return { + list, + all: () => state.all, + add, + remove, + clear, + focus: () => state.focus, + setFocus, + clearFocus: () => setFocus(null), + active: () => state.active, + setActive, + clearActive: () => setActive(null), + reindex: () => setState("all", aggregate(store.comments)), + } +} + +export function createCommentSessionForTest(comments: Record = {}) { + const [store, setStore] = createStore({ comments }) + return createCommentSessionState(store, setStore) +} + +function createCommentSession(dir: string, id: string | undefined) { + const legacy = `${dir}/comments${id ? "/" + id : ""}.v1` + + const [store, setStore, _, ready] = persisted( + Persist.scoped(dir, id, "comments", [legacy]), + createStore({ + comments: {}, + }), + ) + const session = createCommentSessionState(store, setStore) + + createEffect(() => { + if (!ready()) return + session.reindex() }) return { ready, - list, - all, - add, - remove, - clear, - focus: createMemo(() => state.focus), - setFocus, - clearFocus: () => setFocus(null), - active: createMemo(() => state.active), - setActive, - clearActive: () => setActive(null), + list: session.list, + all: session.all, + add: session.add, + remove: session.remove, + clear: session.clear, + focus: session.focus, + setFocus: session.setFocus, + clearFocus: session.clearFocus, + active: session.active, + setActive: session.setActive, + clearActive: session.clearActive, } } @@ -105,44 +142,27 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont gate: false, init: () => { const params = useParams() - const cache = new Map() + const cache = createScopedCache( + (key) => { + const split = key.lastIndexOf("\n") + const dir = split >= 0 ? key.slice(0, split) : key + const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY + return createRoot((dispose) => ({ + value: createCommentSession(dir, id === WORKSPACE_KEY ? undefined : id), + dispose, + })) + }, + { + maxEntries: MAX_COMMENT_SESSIONS, + dispose: (entry) => entry.dispose(), + }, + ) - const disposeAll = () => { - for (const entry of cache.values()) { - entry.dispose() - } - cache.clear() - } - - onCleanup(disposeAll) - - const prune = () => { - while (cache.size > MAX_COMMENT_SESSIONS) { - const first = cache.keys().next().value - if (!first) return - const entry = cache.get(first) - entry?.dispose() - cache.delete(first) - } - } + onCleanup(() => cache.clear()) const load = (dir: string, id: string | undefined) => { - const key = `${dir}:${id ?? WORKSPACE_KEY}` - const existing = cache.get(key) - if (existing) { - cache.delete(key) - cache.set(key, existing) - return existing.value - } - - const entry = createRoot((dispose) => ({ - value: createCommentSession(dir, id), - dispose, - })) - - cache.set(key, entry) - prune() - return entry.value + const key = `${dir}\n${id ?? WORKSPACE_KEY}` + return cache.get(key).value } const session = createMemo(() => load(params.dir!, params.id)) diff --git a/packages/app/src/context/file-content-eviction-accounting.test.ts b/packages/app/src/context/file-content-eviction-accounting.test.ts new file mode 100644 index 0000000000..4ef5f947c7 --- /dev/null +++ b/packages/app/src/context/file-content-eviction-accounting.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { + evictContentLru, + getFileContentBytesTotal, + getFileContentEntryCount, + removeFileContentBytes, + resetFileContentLru, + setFileContentBytes, + touchFileContent, +} from "./file/content-cache" + +describe("file content eviction accounting", () => { + afterEach(() => { + resetFileContentLru() + }) + + test("updates byte totals incrementally for set, overwrite, remove, and reset", () => { + setFileContentBytes("a", 10) + setFileContentBytes("b", 15) + expect(getFileContentBytesTotal()).toBe(25) + expect(getFileContentEntryCount()).toBe(2) + + setFileContentBytes("a", 5) + expect(getFileContentBytesTotal()).toBe(20) + expect(getFileContentEntryCount()).toBe(2) + + touchFileContent("a") + expect(getFileContentBytesTotal()).toBe(20) + + removeFileContentBytes("b") + expect(getFileContentBytesTotal()).toBe(5) + expect(getFileContentEntryCount()).toBe(1) + + resetFileContentLru() + expect(getFileContentBytesTotal()).toBe(0) + expect(getFileContentEntryCount()).toBe(0) + }) + + test("evicts by entry cap using LRU order", () => { + for (const i of Array.from({ length: 41 }, (_, n) => n)) { + setFileContentBytes(`f-${i}`, 1) + } + + const evicted: string[] = [] + evictContentLru(undefined, (path) => evicted.push(path)) + + expect(evicted).toEqual(["f-0"]) + expect(getFileContentEntryCount()).toBe(40) + expect(getFileContentBytesTotal()).toBe(40) + }) + + test("evicts by byte cap while preserving protected entries", () => { + const chunk = 8 * 1024 * 1024 + setFileContentBytes("a", chunk) + setFileContentBytes("b", chunk) + setFileContentBytes("c", chunk) + + const evicted: string[] = [] + evictContentLru(new Set(["a"]), (path) => evicted.push(path)) + + expect(evicted).toEqual(["b"]) + expect(getFileContentEntryCount()).toBe(2) + expect(getFileContentBytesTotal()).toBe(chunk * 2) + }) +}) diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 3ed1b1ae4b..996ea2aafe 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -1,269 +1,45 @@ -import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js" +import { batch, createEffect, createMemo, onCleanup } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" -import type { FileContent, FileNode } from "@opencode-ai/sdk/v2" import { showToast } from "@opencode-ai/ui/toast" import { useParams } from "@solidjs/router" import { getFilename } from "@opencode-ai/util/path" import { useSDK } from "./sdk" import { useSync } from "./sync" import { useLanguage } from "@/context/language" -import { Persist, persisted } from "@/utils/persist" +import { createPathHelpers } from "./file/path" +import { + approxBytes, + evictContentLru, + getFileContentBytesTotal, + getFileContentEntryCount, + hasFileContent, + removeFileContentBytes, + resetFileContentLru, + setFileContentBytes, + touchFileContent, +} from "./file/content-cache" +import { createFileViewCache } from "./file/view-cache" +import { createFileTreeStore } from "./file/tree-store" +import { invalidateFromWatcher } from "./file/watcher" +import { + selectionFromLines, + type FileState, + type FileSelection, + type FileViewState, + type SelectedLineRange, +} from "./file/types" -export type FileSelection = { - startLine: number - startChar: number - endLine: number - endChar: number -} - -export type SelectedLineRange = { - start: number - end: number - side?: "additions" | "deletions" - endSide?: "additions" | "deletions" -} - -export type FileViewState = { - scrollTop?: number - scrollLeft?: number - selectedLines?: SelectedLineRange | null -} - -export type FileState = { - path: string - name: string - loaded?: boolean - loading?: boolean - error?: string - content?: FileContent -} - -type DirectoryState = { - expanded: boolean - loaded?: boolean - loading?: boolean - error?: string - children?: string[] -} - -function stripFileProtocol(input: string) { - if (!input.startsWith("file://")) return input - return input.slice("file://".length) -} - -function stripQueryAndHash(input: string) { - const hashIndex = input.indexOf("#") - const queryIndex = input.indexOf("?") - - if (hashIndex !== -1 && queryIndex !== -1) { - return input.slice(0, Math.min(hashIndex, queryIndex)) - } - - if (hashIndex !== -1) return input.slice(0, hashIndex) - if (queryIndex !== -1) return input.slice(0, queryIndex) - return input -} - -function unquoteGitPath(input: string) { - if (!input.startsWith('"')) return input - if (!input.endsWith('"')) return input - const body = input.slice(1, -1) - const bytes: number[] = [] - - for (let i = 0; i < body.length; i++) { - const char = body[i]! - if (char !== "\\") { - bytes.push(char.charCodeAt(0)) - continue - } - - const next = body[i + 1] - if (!next) { - bytes.push("\\".charCodeAt(0)) - continue - } - - if (next >= "0" && next <= "7") { - const chunk = body.slice(i + 1, i + 4) - const match = chunk.match(/^[0-7]{1,3}/) - if (!match) { - bytes.push(next.charCodeAt(0)) - i++ - continue - } - bytes.push(parseInt(match[0], 8)) - i += match[0].length - continue - } - - const escaped = - next === "n" - ? "\n" - : next === "r" - ? "\r" - : next === "t" - ? "\t" - : next === "b" - ? "\b" - : next === "f" - ? "\f" - : next === "v" - ? "\v" - : next === "\\" || next === '"' - ? next - : undefined - - bytes.push((escaped ?? next).charCodeAt(0)) - i++ - } - - return new TextDecoder().decode(new Uint8Array(bytes)) -} - -export function selectionFromLines(range: SelectedLineRange): FileSelection { - const startLine = Math.min(range.start, range.end) - const endLine = Math.max(range.start, range.end) - return { - startLine, - endLine, - startChar: 0, - endChar: 0, - } -} - -function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange { - if (range.start <= range.end) return range - - const startSide = range.side - const endSide = range.endSide ?? startSide - - return { - ...range, - start: range.end, - end: range.start, - side: endSide, - endSide: startSide !== endSide ? startSide : undefined, - } -} - -const WORKSPACE_KEY = "__workspace__" -const MAX_FILE_VIEW_SESSIONS = 20 -const MAX_VIEW_FILES = 500 - -const MAX_FILE_CONTENT_ENTRIES = 40 -const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024 - -const contentLru = new Map() - -function approxBytes(content: FileContent) { - const patchBytes = - content.patch?.hunks.reduce((total, hunk) => { - return total + hunk.lines.reduce((sum, line) => sum + line.length, 0) - }, 0) ?? 0 - - return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2 -} - -function touchContent(path: string, bytes?: number) { - const prev = contentLru.get(path) - if (prev === undefined && bytes === undefined) return - const value = bytes ?? prev ?? 0 - contentLru.delete(path) - contentLru.set(path, value) -} - -type ViewSession = ReturnType - -type ViewCacheEntry = { - value: ViewSession - dispose: VoidFunction -} - -function createViewSession(dir: string, id: string | undefined) { - const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1` - - const [view, setView, _, ready] = persisted( - Persist.scoped(dir, id, "file-view", [legacyViewKey]), - createStore<{ - file: Record - }>({ - file: {}, - }), - ) - - const meta = { pruned: false } - - const pruneView = (keep?: string) => { - const keys = Object.keys(view.file) - if (keys.length <= MAX_VIEW_FILES) return - - const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES) - if (drop.length === 0) return - - setView( - produce((draft) => { - for (const key of drop) { - delete draft.file[key] - } - }), - ) - } - - createEffect(() => { - if (!ready()) return - if (meta.pruned) return - meta.pruned = true - pruneView() - }) - - const scrollTop = (path: string) => view.file[path]?.scrollTop - const scrollLeft = (path: string) => view.file[path]?.scrollLeft - const selectedLines = (path: string) => view.file[path]?.selectedLines - - const setScrollTop = (path: string, top: number) => { - setView("file", path, (current) => { - if (current?.scrollTop === top) return current - return { - ...(current ?? {}), - scrollTop: top, - } - }) - pruneView(path) - } - - const setScrollLeft = (path: string, left: number) => { - setView("file", path, (current) => { - if (current?.scrollLeft === left) return current - return { - ...(current ?? {}), - scrollLeft: left, - } - }) - pruneView(path) - } - - const setSelectedLines = (path: string, range: SelectedLineRange | null) => { - const next = range ? normalizeSelectedLines(range) : null - setView("file", path, (current) => { - if (current?.selectedLines === next) return current - return { - ...(current ?? {}), - selectedLines: next, - } - }) - pruneView(path) - } - - return { - ready, - scrollTop, - scrollLeft, - selectedLines, - setScrollTop, - setScrollLeft, - setSelectedLines, - } +export type { FileSelection, SelectedLineRange, FileViewState, FileState } +export { selectionFromLines } +export { + evictContentLru, + getFileContentBytesTotal, + getFileContentEntryCount, + removeFileContentBytes, + resetFileContentLru, + setFileContentBytes, + touchFileContent, } export const { use: useFile, provider: FileProvider } = createSimpleContext({ @@ -271,170 +47,75 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ gate: false, init: () => { const sdk = useSDK() - const sync = useSync() + useSync() const params = useParams() const language = useLanguage() const scope = createMemo(() => sdk.directory) - - function normalize(input: string) { - const root = scope() - const prefix = root.endsWith("/") ? root : root + "/" - - let path = unquoteGitPath(stripQueryAndHash(stripFileProtocol(input))) - - if (path.startsWith(prefix)) { - path = path.slice(prefix.length) - } - - if (path.startsWith(root)) { - path = path.slice(root.length) - } - - if (path.startsWith("./")) { - path = path.slice(2) - } - - if (path.startsWith("/")) { - path = path.slice(1) - } - - return path - } - - function tab(input: string) { - const path = normalize(input) - return `file://${path}` - } - - function pathFromTab(tabValue: string) { - if (!tabValue.startsWith("file://")) return - return normalize(tabValue) - } + const path = createPathHelpers(scope) const inflight = new Map>() - const treeInflight = new Map>() - - const search = (query: string, dirs: "true" | "false") => - sdk.client.find.files({ query, dirs }).then( - (x) => (x.data ?? []).map(normalize), - () => [], - ) - const [store, setStore] = createStore<{ file: Record }>({ file: {}, }) - const [tree, setTree] = createStore<{ - node: Record - dir: Record - }>({ - node: {}, - dir: { "": { expanded: true } }, + const tree = createFileTreeStore({ + scope, + normalizeDir: path.normalizeDir, + list: (dir) => sdk.client.file.list({ path: dir }).then((x) => x.data ?? []), + onError: (message) => { + showToast({ + variant: "error", + title: language.t("toast.file.listFailed.title"), + description: message, + }) + }, }) const evictContent = (keep?: Set) => { - const protectedSet = keep ?? new Set() - const total = () => { - return Array.from(contentLru.values()).reduce((sum, bytes) => sum + bytes, 0) - } - - while (contentLru.size > MAX_FILE_CONTENT_ENTRIES || total() > MAX_FILE_CONTENT_BYTES) { - const path = contentLru.keys().next().value - if (!path) return - - if (protectedSet.has(path)) { - touchContent(path) - if (contentLru.size <= protectedSet.size) return - continue - } - - contentLru.delete(path) - if (!store.file[path]) continue + evictContentLru(keep, (target) => { + if (!store.file[target]) return setStore( "file", - path, + target, produce((draft) => { draft.content = undefined draft.loaded = false }), ) - } + }) } createEffect(() => { scope() inflight.clear() - treeInflight.clear() - contentLru.clear() - + resetFileContentLru() batch(() => { setStore("file", reconcile({})) - setTree("node", reconcile({})) - setTree("dir", reconcile({})) - setTree("dir", "", { expanded: true }) + tree.reset() }) }) - const viewCache = new Map() + const viewCache = createFileViewCache() + const view = createMemo(() => viewCache.load(scope(), params.id)) - const disposeViews = () => { - for (const entry of viewCache.values()) { - entry.dispose() - } - viewCache.clear() + const ensure = (file: string) => { + if (!file) return + if (store.file[file]) return + setStore("file", file, { path: file, name: getFilename(file) }) } - const pruneViews = () => { - while (viewCache.size > MAX_FILE_VIEW_SESSIONS) { - const first = viewCache.keys().next().value - if (!first) return - const entry = viewCache.get(first) - entry?.dispose() - viewCache.delete(first) - } - } - - const loadView = (dir: string, id: string | undefined) => { - const key = `${dir}:${id ?? WORKSPACE_KEY}` - const existing = viewCache.get(key) - if (existing) { - viewCache.delete(key) - viewCache.set(key, existing) - return existing.value - } - - const entry = createRoot((dispose) => ({ - value: createViewSession(dir, id), - dispose, - })) - - viewCache.set(key, entry) - pruneViews() - return entry.value - } - - const view = createMemo(() => loadView(scope(), params.id)) - - function ensure(path: string) { - if (!path) return - if (store.file[path]) return - setStore("file", path, { path, name: getFilename(path) }) - } - - function load(input: string, options?: { force?: boolean }) { - const path = normalize(input) - if (!path) return Promise.resolve() + const load = (input: string, options?: { force?: boolean }) => { + const file = path.normalize(input) + if (!file) return Promise.resolve() const directory = scope() - const key = `${directory}\n${path}` - const client = sdk.client + const key = `${directory}\n${file}` + ensure(file) - ensure(path) - - const current = store.file[path] + const current = store.file[file] if (!options?.force && current?.loaded) return Promise.resolve() const pending = inflight.get(key) @@ -442,21 +123,21 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ setStore( "file", - path, + file, produce((draft) => { draft.loading = true draft.error = undefined }), ) - const promise = client.file - .read({ path }) + const promise = sdk.client.file + .read({ path: file }) .then((x) => { if (scope() !== directory) return const content = x.data setStore( "file", - path, + file, produce((draft) => { draft.loaded = true draft.loading = false @@ -465,14 +146,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ ) if (!content) return - touchContent(path, approxBytes(content)) - evictContent(new Set([path])) + touchFileContent(file, approxBytes(content)) + evictContent(new Set([file])) }) .catch((e) => { if (scope() !== directory) return setStore( "file", - path, + file, produce((draft) => { draft.loading = false draft.error = e.message @@ -492,225 +173,79 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ return promise } - function normalizeDir(input: string) { - return normalize(input).replace(/\/+$/, "") - } - - function ensureDir(path: string) { - if (tree.dir[path]) return - setTree("dir", path, { expanded: false }) - } - - function listDir(input: string, options?: { force?: boolean }) { - const dir = normalizeDir(input) - ensureDir(dir) - - const current = tree.dir[dir] - if (!options?.force && current?.loaded) return Promise.resolve() - - const pending = treeInflight.get(dir) - if (pending) return pending - - setTree( - "dir", - dir, - produce((draft) => { - draft.loading = true - draft.error = undefined - }), + const search = (query: string, dirs: "true" | "false") => + sdk.client.find.files({ query, dirs }).then( + (x) => (x.data ?? []).map(path.normalize), + () => [], ) - const directory = scope() - - const promise = sdk.client.file - .list({ path: dir }) - .then((x) => { - if (scope() !== directory) return - const nodes = x.data ?? [] - const prevChildren = tree.dir[dir]?.children ?? [] - const nextChildren = nodes.map((node) => node.path) - const nextSet = new Set(nextChildren) - - setTree( - "node", - produce((draft) => { - const removedDirs: string[] = [] - - for (const child of prevChildren) { - if (nextSet.has(child)) continue - const existing = draft[child] - if (existing?.type === "directory") removedDirs.push(child) - delete draft[child] - } - - if (removedDirs.length > 0) { - const keys = Object.keys(draft) - for (const key of keys) { - for (const removed of removedDirs) { - if (!key.startsWith(removed + "/")) continue - delete draft[key] - break - } - } - } - - for (const node of nodes) { - draft[node.path] = node - } - }), - ) - - setTree( - "dir", - dir, - produce((draft) => { - draft.loaded = true - draft.loading = false - draft.children = nextChildren - }), - ) - }) - .catch((e) => { - if (scope() !== directory) return - setTree( - "dir", - dir, - produce((draft) => { - draft.loading = false - draft.error = e.message - }), - ) - showToast({ - variant: "error", - title: language.t("toast.file.listFailed.title"), - description: e.message, - }) - }) - .finally(() => { - treeInflight.delete(dir) - }) - - treeInflight.set(dir, promise) - return promise - } - - function expandDir(input: string) { - const dir = normalizeDir(input) - ensureDir(dir) - setTree("dir", dir, "expanded", true) - void listDir(dir) - } - - function collapseDir(input: string) { - const dir = normalizeDir(input) - ensureDir(dir) - setTree("dir", dir, "expanded", false) - } - - function dirState(input: string) { - const dir = normalizeDir(input) - return tree.dir[dir] - } - - function children(input: string) { - const dir = normalizeDir(input) - const ids = tree.dir[dir]?.children - if (!ids) return [] - const out: FileNode[] = [] - for (const id of ids) { - const node = tree.node[id] - if (node) out.push(node) - } - return out - } - const stop = sdk.event.listen((e) => { - const event = e.details - if (event.type !== "file.watcher.updated") return - const path = normalize(event.properties.file) - if (!path) return - if (path.startsWith(".git/")) return - - if (store.file[path]) { - load(path, { force: true }) - } - - const kind = event.properties.event - if (kind === "change") { - const dir = (() => { - if (path === "") return "" - const node = tree.node[path] - if (node?.type !== "directory") return - return path - })() - if (dir === undefined) return - if (!tree.dir[dir]?.loaded) return - listDir(dir, { force: true }) - return - } - if (kind !== "add" && kind !== "unlink") return - - const parent = path.split("/").slice(0, -1).join("/") - if (!tree.dir[parent]?.loaded) return - - listDir(parent, { force: true }) + invalidateFromWatcher(e.details, { + normalize: path.normalize, + hasFile: (file) => Boolean(store.file[file]), + loadFile: (file) => { + void load(file, { force: true }) + }, + node: tree.node, + isDirLoaded: tree.isLoaded, + refreshDir: (dir) => { + void tree.listDir(dir, { force: true }) + }, + }) }) const get = (input: string) => { - const path = normalize(input) - const file = store.file[path] - const content = file?.content - if (!content) return file - if (contentLru.has(path)) { - touchContent(path) - return file + const file = path.normalize(input) + const state = store.file[file] + const content = state?.content + if (!content) return state + if (hasFileContent(file)) { + touchFileContent(file) + return state } - touchContent(path, approxBytes(content)) - return file + touchFileContent(file, approxBytes(content)) + return state } - const scrollTop = (input: string) => view().scrollTop(normalize(input)) - const scrollLeft = (input: string) => view().scrollLeft(normalize(input)) - const selectedLines = (input: string) => view().selectedLines(normalize(input)) + const scrollTop = (input: string) => view().scrollTop(path.normalize(input)) + const scrollLeft = (input: string) => view().scrollLeft(path.normalize(input)) + const selectedLines = (input: string) => view().selectedLines(path.normalize(input)) const setScrollTop = (input: string, top: number) => { - const path = normalize(input) - view().setScrollTop(path, top) + view().setScrollTop(path.normalize(input), top) } const setScrollLeft = (input: string, left: number) => { - const path = normalize(input) - view().setScrollLeft(path, left) + view().setScrollLeft(path.normalize(input), left) } const setSelectedLines = (input: string, range: SelectedLineRange | null) => { - const path = normalize(input) - view().setSelectedLines(path, range) + view().setSelectedLines(path.normalize(input), range) } onCleanup(() => { stop() - disposeViews() + viewCache.clear() }) return { ready: () => view().ready(), - normalize, - tab, - pathFromTab, + normalize: path.normalize, + tab: path.tab, + pathFromTab: path.pathFromTab, tree: { - list: listDir, - refresh: (input: string) => listDir(input, { force: true }), - state: dirState, - children, - expand: expandDir, - collapse: collapseDir, + list: tree.listDir, + refresh: (input: string) => tree.listDir(input, { force: true }), + state: tree.dirState, + children: tree.children, + expand: tree.expandDir, + collapse: tree.collapseDir, toggle(input: string) { - if (dirState(input)?.expanded) { - collapseDir(input) + if (tree.dirState(input)?.expanded) { + tree.collapseDir(input) return } - expandDir(input) + tree.expandDir(input) }, }, get, diff --git a/packages/app/src/context/file/content-cache.ts b/packages/app/src/context/file/content-cache.ts new file mode 100644 index 0000000000..4b72406883 --- /dev/null +++ b/packages/app/src/context/file/content-cache.ts @@ -0,0 +1,88 @@ +import type { FileContent } from "@opencode-ai/sdk/v2" + +const MAX_FILE_CONTENT_ENTRIES = 40 +const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024 + +const lru = new Map() +let total = 0 + +export function approxBytes(content: FileContent) { + const patchBytes = + content.patch?.hunks.reduce((sum, hunk) => { + return sum + hunk.lines.reduce((lineSum, line) => lineSum + line.length, 0) + }, 0) ?? 0 + + return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2 +} + +function setBytes(path: string, nextBytes: number) { + const prev = lru.get(path) + if (prev !== undefined) total -= prev + lru.delete(path) + lru.set(path, nextBytes) + total += nextBytes +} + +function touch(path: string, bytes?: number) { + const prev = lru.get(path) + if (prev === undefined && bytes === undefined) return + setBytes(path, bytes ?? prev ?? 0) +} + +function remove(path: string) { + const prev = lru.get(path) + if (prev === undefined) return + lru.delete(path) + total -= prev +} + +function reset() { + lru.clear() + total = 0 +} + +export function evictContentLru(keep: Set | undefined, evict: (path: string) => void) { + const set = keep ?? new Set() + + while (lru.size > MAX_FILE_CONTENT_ENTRIES || total > MAX_FILE_CONTENT_BYTES) { + const path = lru.keys().next().value + if (!path) return + + if (set.has(path)) { + touch(path) + if (lru.size <= set.size) return + continue + } + + remove(path) + evict(path) + } +} + +export function resetFileContentLru() { + reset() +} + +export function setFileContentBytes(path: string, bytes: number) { + setBytes(path, bytes) +} + +export function removeFileContentBytes(path: string) { + remove(path) +} + +export function touchFileContent(path: string, bytes?: number) { + touch(path, bytes) +} + +export function getFileContentBytesTotal() { + return total +} + +export function getFileContentEntryCount() { + return lru.size +} + +export function hasFileContent(path: string) { + return lru.has(path) +} diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts new file mode 100644 index 0000000000..dba9ae06dc --- /dev/null +++ b/packages/app/src/context/file/path.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test" +import { createPathHelpers, stripQueryAndHash, unquoteGitPath } from "./path" + +describe("file path helpers", () => { + test("normalizes file inputs against workspace root", () => { + const path = createPathHelpers(() => "/repo") + expect(path.normalize("file:///repo/src/app.ts?x=1#h")).toBe("src/app.ts") + expect(path.normalize("/repo/src/app.ts")).toBe("src/app.ts") + expect(path.normalize("./src/app.ts")).toBe("src/app.ts") + expect(path.normalizeDir("src/components///")).toBe("src/components") + expect(path.tab("src/app.ts")).toBe("file://src/app.ts") + expect(path.pathFromTab("file://src/app.ts")).toBe("src/app.ts") + expect(path.pathFromTab("other://src/app.ts")).toBeUndefined() + }) + + test("keeps query/hash stripping behavior stable", () => { + expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts") + expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts") + expect(stripQueryAndHash("a/b.ts")).toBe("a/b.ts") + }) + + test("unquotes git escaped octal path strings", () => { + expect(unquoteGitPath('"a/\\303\\251.txt"')).toBe("a/\u00e9.txt") + expect(unquoteGitPath('"plain\\nname"')).toBe("plain\nname") + expect(unquoteGitPath("a/b/c.ts")).toBe("a/b/c.ts") + }) +}) diff --git a/packages/app/src/context/file/path.ts b/packages/app/src/context/file/path.ts new file mode 100644 index 0000000000..155f05aafa --- /dev/null +++ b/packages/app/src/context/file/path.ts @@ -0,0 +1,134 @@ +export function stripFileProtocol(input: string) { + if (!input.startsWith("file://")) return input + return input.slice("file://".length) +} + +export function stripQueryAndHash(input: string) { + const hashIndex = input.indexOf("#") + const queryIndex = input.indexOf("?") + + if (hashIndex !== -1 && queryIndex !== -1) { + return input.slice(0, Math.min(hashIndex, queryIndex)) + } + + if (hashIndex !== -1) return input.slice(0, hashIndex) + if (queryIndex !== -1) return input.slice(0, queryIndex) + return input +} + +export function unquoteGitPath(input: string) { + if (!input.startsWith('"')) return input + if (!input.endsWith('"')) return input + const body = input.slice(1, -1) + const bytes: number[] = [] + + for (let i = 0; i < body.length; i++) { + const char = body[i]! + if (char !== "\\") { + bytes.push(char.charCodeAt(0)) + continue + } + + const next = body[i + 1] + if (!next) { + bytes.push("\\".charCodeAt(0)) + continue + } + + if (next >= "0" && next <= "7") { + const chunk = body.slice(i + 1, i + 4) + const match = chunk.match(/^[0-7]{1,3}/) + if (!match) { + bytes.push(next.charCodeAt(0)) + i++ + continue + } + bytes.push(parseInt(match[0], 8)) + i += match[0].length + continue + } + + const escaped = + next === "n" + ? "\n" + : next === "r" + ? "\r" + : next === "t" + ? "\t" + : next === "b" + ? "\b" + : next === "f" + ? "\f" + : next === "v" + ? "\v" + : next === "\\" || next === '"' + ? next + : undefined + + bytes.push((escaped ?? next).charCodeAt(0)) + i++ + } + + return new TextDecoder().decode(new Uint8Array(bytes)) +} + +export function decodeFilePath(input: string) { + try { + return decodeURIComponent(input) + } catch { + return input + } +} + +export function encodeFilePath(filepath: string): string { + return filepath + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/") +} + +export function createPathHelpers(scope: () => string) { + const normalize = (input: string) => { + const root = scope() + const prefix = root.endsWith("/") ? root : root + "/" + + let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input)))) + + if (path.startsWith(prefix)) { + path = path.slice(prefix.length) + } + + if (path.startsWith(root)) { + path = path.slice(root.length) + } + + if (path.startsWith("./")) { + path = path.slice(2) + } + + if (path.startsWith("/")) { + path = path.slice(1) + } + + return path + } + + const tab = (input: string) => { + const path = normalize(input) + return `file://${encodeFilePath(path)}` + } + + const pathFromTab = (tabValue: string) => { + if (!tabValue.startsWith("file://")) return + return normalize(tabValue) + } + + const normalizeDir = (input: string) => normalize(input).replace(/\/+$/, "") + + return { + normalize, + tab, + pathFromTab, + normalizeDir, + } +} diff --git a/packages/app/src/context/file/tree-store.ts b/packages/app/src/context/file/tree-store.ts new file mode 100644 index 0000000000..a86051d286 --- /dev/null +++ b/packages/app/src/context/file/tree-store.ts @@ -0,0 +1,170 @@ +import { createStore, produce, reconcile } from "solid-js/store" +import type { FileNode } from "@opencode-ai/sdk/v2" + +type DirectoryState = { + expanded: boolean + loaded?: boolean + loading?: boolean + error?: string + children?: string[] +} + +type TreeStoreOptions = { + scope: () => string + normalizeDir: (input: string) => string + list: (input: string) => Promise + onError: (message: string) => void +} + +export function createFileTreeStore(options: TreeStoreOptions) { + const [tree, setTree] = createStore<{ + node: Record + dir: Record + }>({ + node: {}, + dir: { "": { expanded: true } }, + }) + + const inflight = new Map>() + + const reset = () => { + inflight.clear() + setTree("node", reconcile({})) + setTree("dir", reconcile({})) + setTree("dir", "", { expanded: true }) + } + + const ensureDir = (path: string) => { + if (tree.dir[path]) return + setTree("dir", path, { expanded: false }) + } + + const listDir = (input: string, opts?: { force?: boolean }) => { + const dir = options.normalizeDir(input) + ensureDir(dir) + + const current = tree.dir[dir] + if (!opts?.force && current?.loaded) return Promise.resolve() + + const pending = inflight.get(dir) + if (pending) return pending + + setTree( + "dir", + dir, + produce((draft) => { + draft.loading = true + draft.error = undefined + }), + ) + + const directory = options.scope() + + const promise = options + .list(dir) + .then((nodes) => { + if (options.scope() !== directory) return + const prevChildren = tree.dir[dir]?.children ?? [] + const nextChildren = nodes.map((node) => node.path) + const nextSet = new Set(nextChildren) + + setTree( + "node", + produce((draft) => { + const removedDirs: string[] = [] + + for (const child of prevChildren) { + if (nextSet.has(child)) continue + const existing = draft[child] + if (existing?.type === "directory") removedDirs.push(child) + delete draft[child] + } + + if (removedDirs.length > 0) { + const keys = Object.keys(draft) + for (const key of keys) { + for (const removed of removedDirs) { + if (!key.startsWith(removed + "/")) continue + delete draft[key] + break + } + } + } + + for (const node of nodes) { + draft[node.path] = node + } + }), + ) + + setTree( + "dir", + dir, + produce((draft) => { + draft.loaded = true + draft.loading = false + draft.children = nextChildren + }), + ) + }) + .catch((e) => { + if (options.scope() !== directory) return + setTree( + "dir", + dir, + produce((draft) => { + draft.loading = false + draft.error = e.message + }), + ) + options.onError(e.message) + }) + .finally(() => { + inflight.delete(dir) + }) + + inflight.set(dir, promise) + return promise + } + + const expandDir = (input: string) => { + const dir = options.normalizeDir(input) + ensureDir(dir) + setTree("dir", dir, "expanded", true) + void listDir(dir) + } + + const collapseDir = (input: string) => { + const dir = options.normalizeDir(input) + ensureDir(dir) + setTree("dir", dir, "expanded", false) + } + + const dirState = (input: string) => { + const dir = options.normalizeDir(input) + return tree.dir[dir] + } + + const children = (input: string) => { + const dir = options.normalizeDir(input) + const ids = tree.dir[dir]?.children + if (!ids) return [] + const out: FileNode[] = [] + for (const id of ids) { + const node = tree.node[id] + if (node) out.push(node) + } + return out + } + + return { + listDir, + expandDir, + collapseDir, + dirState, + children, + node: (path: string) => tree.node[path], + isLoaded: (path: string) => Boolean(tree.dir[path]?.loaded), + reset, + } +} diff --git a/packages/app/src/context/file/types.ts b/packages/app/src/context/file/types.ts new file mode 100644 index 0000000000..7ce8a37c25 --- /dev/null +++ b/packages/app/src/context/file/types.ts @@ -0,0 +1,41 @@ +import type { FileContent } from "@opencode-ai/sdk/v2" + +export type FileSelection = { + startLine: number + startChar: number + endLine: number + endChar: number +} + +export type SelectedLineRange = { + start: number + end: number + side?: "additions" | "deletions" + endSide?: "additions" | "deletions" +} + +export type FileViewState = { + scrollTop?: number + scrollLeft?: number + selectedLines?: SelectedLineRange | null +} + +export type FileState = { + path: string + name: string + loaded?: boolean + loading?: boolean + error?: string + content?: FileContent +} + +export function selectionFromLines(range: SelectedLineRange): FileSelection { + const startLine = Math.min(range.start, range.end) + const endLine = Math.max(range.start, range.end) + return { + startLine, + endLine, + startChar: 0, + endChar: 0, + } +} diff --git a/packages/app/src/context/file/view-cache.ts b/packages/app/src/context/file/view-cache.ts new file mode 100644 index 0000000000..2614b2fb53 --- /dev/null +++ b/packages/app/src/context/file/view-cache.ts @@ -0,0 +1,136 @@ +import { createEffect, createRoot } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { Persist, persisted } from "@/utils/persist" +import { createScopedCache } from "@/utils/scoped-cache" +import type { FileViewState, SelectedLineRange } from "./types" + +const WORKSPACE_KEY = "__workspace__" +const MAX_FILE_VIEW_SESSIONS = 20 +const MAX_VIEW_FILES = 500 + +function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange { + if (range.start <= range.end) return range + + const startSide = range.side + const endSide = range.endSide ?? startSide + + return { + ...range, + start: range.end, + end: range.start, + side: endSide, + endSide: startSide !== endSide ? startSide : undefined, + } +} + +function createViewSession(dir: string, id: string | undefined) { + const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1` + + const [view, setView, _, ready] = persisted( + Persist.scoped(dir, id, "file-view", [legacyViewKey]), + createStore<{ + file: Record + }>({ + file: {}, + }), + ) + + const meta = { pruned: false } + + const pruneView = (keep?: string) => { + const keys = Object.keys(view.file) + if (keys.length <= MAX_VIEW_FILES) return + + const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES) + if (drop.length === 0) return + + setView( + produce((draft) => { + for (const key of drop) { + delete draft.file[key] + } + }), + ) + } + + createEffect(() => { + if (!ready()) return + if (meta.pruned) return + meta.pruned = true + pruneView() + }) + + const scrollTop = (path: string) => view.file[path]?.scrollTop + const scrollLeft = (path: string) => view.file[path]?.scrollLeft + const selectedLines = (path: string) => view.file[path]?.selectedLines + + const setScrollTop = (path: string, top: number) => { + setView("file", path, (current) => { + if (current?.scrollTop === top) return current + return { + ...(current ?? {}), + scrollTop: top, + } + }) + pruneView(path) + } + + const setScrollLeft = (path: string, left: number) => { + setView("file", path, (current) => { + if (current?.scrollLeft === left) return current + return { + ...(current ?? {}), + scrollLeft: left, + } + }) + pruneView(path) + } + + const setSelectedLines = (path: string, range: SelectedLineRange | null) => { + const next = range ? normalizeSelectedLines(range) : null + setView("file", path, (current) => { + if (current?.selectedLines === next) return current + return { + ...(current ?? {}), + selectedLines: next, + } + }) + pruneView(path) + } + + return { + ready, + scrollTop, + scrollLeft, + selectedLines, + setScrollTop, + setScrollLeft, + setSelectedLines, + } +} + +export function createFileViewCache() { + const cache = createScopedCache( + (key) => { + const split = key.lastIndexOf("\n") + const dir = split >= 0 ? key.slice(0, split) : key + const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY + return createRoot((dispose) => ({ + value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id), + dispose, + })) + }, + { + maxEntries: MAX_FILE_VIEW_SESSIONS, + dispose: (entry) => entry.dispose(), + }, + ) + + return { + load: (dir: string, id: string | undefined) => { + const key = `${dir}\n${id ?? WORKSPACE_KEY}` + return cache.get(key).value + }, + clear: () => cache.clear(), + } +} diff --git a/packages/app/src/context/file/watcher.test.ts b/packages/app/src/context/file/watcher.test.ts new file mode 100644 index 0000000000..653e0aa752 --- /dev/null +++ b/packages/app/src/context/file/watcher.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test" +import { invalidateFromWatcher } from "./watcher" + +describe("file watcher invalidation", () => { + test("reloads open files and refreshes loaded parent on add", () => { + const loads: string[] = [] + const refresh: string[] = [] + invalidateFromWatcher( + { + type: "file.watcher.updated", + properties: { + file: "src/new.ts", + event: "add", + }, + }, + { + normalize: (input) => input, + hasFile: (path) => path === "src/new.ts", + loadFile: (path) => loads.push(path), + node: () => undefined, + isDirLoaded: (path) => path === "src", + refreshDir: (path) => refresh.push(path), + }, + ) + + expect(loads).toEqual(["src/new.ts"]) + expect(refresh).toEqual(["src"]) + }) + + test("refreshes only changed loaded directory nodes", () => { + const refresh: string[] = [] + + invalidateFromWatcher( + { + type: "file.watcher.updated", + properties: { + file: "src", + event: "change", + }, + }, + { + normalize: (input) => input, + hasFile: () => false, + loadFile: () => {}, + node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }), + isDirLoaded: (path) => path === "src", + refreshDir: (path) => refresh.push(path), + }, + ) + + invalidateFromWatcher( + { + type: "file.watcher.updated", + properties: { + file: "src/file.ts", + event: "change", + }, + }, + { + normalize: (input) => input, + hasFile: () => false, + loadFile: () => {}, + node: () => ({ + path: "src/file.ts", + type: "file", + name: "file.ts", + absolute: "/repo/src/file.ts", + ignored: false, + }), + isDirLoaded: () => true, + refreshDir: (path) => refresh.push(path), + }, + ) + + expect(refresh).toEqual(["src"]) + }) + + test("ignores invalid or git watcher updates", () => { + const refresh: string[] = [] + + invalidateFromWatcher( + { + type: "file.watcher.updated", + properties: { + file: ".git/index.lock", + event: "change", + }, + }, + { + normalize: (input) => input, + hasFile: () => true, + loadFile: () => { + throw new Error("should not load") + }, + node: () => undefined, + isDirLoaded: () => true, + refreshDir: (path) => refresh.push(path), + }, + ) + + invalidateFromWatcher( + { + type: "project.updated", + properties: {}, + }, + { + normalize: (input) => input, + hasFile: () => false, + loadFile: () => {}, + node: () => undefined, + isDirLoaded: () => true, + refreshDir: (path) => refresh.push(path), + }, + ) + + expect(refresh).toEqual([]) + }) +}) diff --git a/packages/app/src/context/file/watcher.ts b/packages/app/src/context/file/watcher.ts new file mode 100644 index 0000000000..a3a98eae4b --- /dev/null +++ b/packages/app/src/context/file/watcher.ts @@ -0,0 +1,52 @@ +import type { FileNode } from "@opencode-ai/sdk/v2" + +type WatcherEvent = { + type: string + properties: unknown +} + +type WatcherOps = { + normalize: (input: string) => string + hasFile: (path: string) => boolean + loadFile: (path: string) => void + node: (path: string) => FileNode | undefined + isDirLoaded: (path: string) => boolean + refreshDir: (path: string) => void +} + +export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { + if (event.type !== "file.watcher.updated") return + const props = + typeof event.properties === "object" && event.properties ? (event.properties as Record) : undefined + const rawPath = typeof props?.file === "string" ? props.file : undefined + const kind = typeof props?.event === "string" ? props.event : undefined + if (!rawPath) return + if (!kind) return + + const path = ops.normalize(rawPath) + if (!path) return + if (path.startsWith(".git/")) return + + if (ops.hasFile(path)) { + ops.loadFile(path) + } + + if (kind === "change") { + const dir = (() => { + if (path === "") return "" + const node = ops.node(path) + if (node?.type !== "directory") return + return path + })() + if (dir === undefined) return + if (!ops.isDirLoaded(dir)) return + ops.refreshDir(dir) + return + } + if (kind !== "add" && kind !== "unlink") return + + const parent = path.split("/").slice(0, -1).join("/") + if (!ops.isDirLoaded(parent)) return + + ops.refreshDir(parent) +} diff --git a/packages/app/src/context/global-sync.test.ts b/packages/app/src/context/global-sync.test.ts new file mode 100644 index 0000000000..396b412318 --- /dev/null +++ b/packages/app/src/context/global-sync.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from "bun:test" +import { + canDisposeDirectory, + estimateRootSessionTotal, + loadRootSessionsWithFallback, + pickDirectoriesToEvict, +} from "./global-sync" + +describe("pickDirectoriesToEvict", () => { + test("keeps pinned stores and evicts idle stores", () => { + const now = 5_000 + const picks = pickDirectoriesToEvict({ + stores: ["a", "b", "c", "d"], + state: new Map([ + ["a", { lastAccessAt: 1_000 }], + ["b", { lastAccessAt: 4_900 }], + ["c", { lastAccessAt: 4_800 }], + ["d", { lastAccessAt: 3_000 }], + ]), + pins: new Set(["a"]), + max: 2, + ttl: 1_500, + now, + }) + + expect(picks).toEqual(["d", "c"]) + }) +}) + +describe("loadRootSessionsWithFallback", () => { + test("uses limited roots query when supported", async () => { + const calls: Array<{ directory: string; roots: true; limit?: number }> = [] + let fallback = 0 + + const result = await loadRootSessionsWithFallback({ + directory: "dir", + limit: 10, + list: async (query) => { + calls.push(query) + return { data: [] } + }, + onFallback: () => { + fallback += 1 + }, + }) + + expect(result.data).toEqual([]) + expect(result.limited).toBe(true) + expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }]) + expect(fallback).toBe(0) + }) + + test("falls back to full roots query on limited-query failure", async () => { + const calls: Array<{ directory: string; roots: true; limit?: number }> = [] + let fallback = 0 + + const result = await loadRootSessionsWithFallback({ + directory: "dir", + limit: 25, + list: async (query) => { + calls.push(query) + if (query.limit) throw new Error("unsupported") + return { data: [] } + }, + onFallback: () => { + fallback += 1 + }, + }) + + expect(result.data).toEqual([]) + expect(result.limited).toBe(false) + expect(calls).toEqual([ + { directory: "dir", roots: true, limit: 25 }, + { directory: "dir", roots: true }, + ]) + expect(fallback).toBe(1) + }) +}) + +describe("estimateRootSessionTotal", () => { + test("keeps exact total for full fetches", () => { + expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42) + }) + + test("marks has-more for full-limit limited fetches", () => { + expect(estimateRootSessionTotal({ count: 10, limit: 10, limited: true })).toBe(11) + }) + + test("keeps exact total when limited fetch is under limit", () => { + expect(estimateRootSessionTotal({ count: 9, limit: 10, limited: true })).toBe(9) + }) +}) + +describe("canDisposeDirectory", () => { + test("rejects pinned or inflight directories", () => { + expect( + canDisposeDirectory({ + directory: "dir", + hasStore: true, + pinned: true, + booting: false, + loadingSessions: false, + }), + ).toBe(false) + expect( + canDisposeDirectory({ + directory: "dir", + hasStore: true, + pinned: false, + booting: true, + loadingSessions: false, + }), + ).toBe(false) + expect( + canDisposeDirectory({ + directory: "dir", + hasStore: true, + pinned: false, + booting: false, + loadingSessions: true, + }), + ).toBe(false) + }) + + test("accepts idle unpinned directory store", () => { + expect( + canDisposeDirectory({ + directory: "dir", + hasStore: true, + pinned: false, + booting: false, + loadingSessions: false, + }), + ).toBe(true) + }) +}) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index a42d5cbd5a..e2bf449807 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -1,40 +1,22 @@ import { - type Message, - type Agent, - type Session, - type Part, type Config, type Path, type Project, - type FileDiff, - type Todo, - type SessionStatus, - type ProviderListResponse, type ProviderAuthResponse, - type Command, - type McpStatus, - type LspStatus, - type VcsInfo, - type PermissionRequest, - type QuestionRequest, + type ProviderListResponse, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" -import { createStore, produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" -import { Binary } from "@opencode-ai/util/binary" -import { retry } from "@opencode-ai/util/retry" +import { createStore, produce, reconcile } from "solid-js/store" import { useGlobalSDK } from "./global-sdk" import type { InitError } from "../pages/error" import { - batch, createContext, createEffect, untrack, getOwner, - runWithOwner, useContext, onCleanup, onMount, - type Accessor, type ParentProps, Switch, Match, @@ -44,91 +26,25 @@ import { getFilename } from "@opencode-ai/util/path" import { usePlatform } from "./platform" import { useLanguage } from "@/context/language" import { Persist, persisted } from "@/utils/persist" +import { createRefreshQueue } from "./global-sync/queue" +import { createChildStoreManager } from "./global-sync/child-store" +import { trimSessions } from "./global-sync/session-trim" +import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" +import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" +import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap" +import { sanitizeProject } from "./global-sync/utils" +import type { ProjectMeta } from "./global-sync/types" +import { SESSION_RECENT_LIMIT } from "./global-sync/types" -type ProjectMeta = { - name?: string - icon?: { - override?: string - color?: string - } - commands?: { - start?: string - } -} - -type State = { - status: "loading" | "partial" | "complete" - agent: Agent[] - command: Command[] - project: string - projectMeta: ProjectMeta | undefined - icon: string | undefined - provider: ProviderListResponse - config: Config +type GlobalStore = { + ready: boolean + error?: InitError path: Path - session: Session[] - sessionTotal: number - session_status: { - [sessionID: string]: SessionStatus - } - session_diff: { - [sessionID: string]: FileDiff[] - } - todo: { - [sessionID: string]: Todo[] - } - permission: { - [sessionID: string]: PermissionRequest[] - } - question: { - [sessionID: string]: QuestionRequest[] - } - mcp: { - [name: string]: McpStatus - } - lsp: LspStatus[] - vcs: VcsInfo | undefined - limit: number - message: { - [sessionID: string]: Message[] - } - part: { - [messageID: string]: Part[] - } -} - -type VcsCache = { - store: Store<{ value: VcsInfo | undefined }> - setStore: SetStoreFunction<{ value: VcsInfo | undefined }> - ready: Accessor -} - -type MetaCache = { - store: Store<{ value: ProjectMeta | undefined }> - setStore: SetStoreFunction<{ value: ProjectMeta | undefined }> - ready: Accessor -} - -type IconCache = { - store: Store<{ value: string | undefined }> - setStore: SetStoreFunction<{ value: string | undefined }> - ready: Accessor -} - -type ChildOptions = { - bootstrap?: boolean -} - -const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) - -function normalizeProviderList(input: ProviderListResponse): ProviderListResponse { - return { - ...input, - all: input.all.map((provider) => ({ - ...provider, - models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")), - })), - } + project: Project[] + provider: ProviderListResponse + provider_auth: ProviderAuthResponse + config: Config + reload: undefined | "pending" | "complete" } function createGlobalSync() { @@ -137,51 +53,23 @@ function createGlobalSync() { const language = useLanguage() const owner = getOwner() if (!owner) throw new Error("GlobalSync must be created within owner") - const vcsCache = new Map() - const metaCache = new Map() - const iconCache = new Map() + + const stats = { + evictions: 0, + loadSessionsFallback: 0, + } const sdkCache = new Map>() - const sdkFor = (directory: string) => { - const cached = sdkCache.get(directory) - if (cached) return cached - - const sdk = createOpencodeClient({ - baseUrl: globalSDK.url, - fetch: platform.fetch, - directory, - throwOnError: true, - }) - sdkCache.set(directory, sdk) - return sdk - } + const booting = new Map>() + const sessionLoads = new Map>() + const sessionMeta = new Map() const [projectCache, setProjectCache, , projectCacheReady] = persisted( Persist.global("globalSync.project", ["globalSync.project.v1"]), createStore({ value: [] as Project[] }), ) - const sanitizeProject = (project: Project) => { - if (!project.icon?.url && !project.icon?.override) return project - return { - ...project, - icon: { - ...project.icon, - url: undefined, - override: undefined, - }, - } - } - const [globalStore, setGlobalStore] = createStore<{ - ready: boolean - error?: InitError - path: Path - project: Project[] - provider: ProviderListResponse - provider_auth: ProviderAuthResponse - config: Config - reload: undefined | "pending" | "complete" - }>({ + const [globalStore, setGlobalStore] = createStore({ ready: false, path: { state: "", config: "", worktree: "", directory: "", home: "" }, project: projectCache.value, @@ -191,72 +79,61 @@ function createGlobalSync() { reload: undefined, }) - const queued = new Set() - let root = false - let running = false - let timer: ReturnType | undefined + const updateStats = (activeDirectoryStores: number) => { + if (!import.meta.env.DEV) return + ;( + globalThis as { + __OPENCODE_GLOBAL_SYNC_STATS?: { + activeDirectoryStores: number + evictions: number + loadSessionsFullFetchFallback: number + } + } + ).__OPENCODE_GLOBAL_SYNC_STATS = { + activeDirectoryStores, + evictions: stats.evictions, + loadSessionsFullFetchFallback: stats.loadSessionsFallback, + } + } const paused = () => untrack(() => globalStore.reload) !== undefined - const tick = () => new Promise((resolve) => setTimeout(resolve, 0)) + const queue = createRefreshQueue({ + paused, + bootstrap, + bootstrapInstance, + }) - const take = (count: number) => { - if (queued.size === 0) return [] as string[] - const items: string[] = [] - for (const item of queued) { - queued.delete(item) - items.push(item) - if (items.length >= count) break - } - return items - } + const children = createChildStoreManager({ + owner, + markStats: updateStats, + incrementEvictions: () => { + stats.evictions += 1 + updateStats(Object.keys(children.children).length) + }, + isBooting: (directory) => booting.has(directory), + isLoadingSessions: (directory) => sessionLoads.has(directory), + onBootstrap: (directory) => { + void bootstrapInstance(directory) + }, + onDispose: (directory) => { + queue.clear(directory) + sessionMeta.delete(directory) + sdkCache.delete(directory) + }, + }) - const schedule = () => { - if (timer) return - timer = setTimeout(() => { - timer = undefined - void drain() - }, 0) - } - - const push = (directory: string) => { - if (!directory) return - queued.add(directory) - if (paused()) return - schedule() - } - - const refresh = () => { - root = true - if (paused()) return - schedule() - } - - async function drain() { - if (running) return - running = true - try { - while (true) { - if (paused()) return - - if (root) { - root = false - await bootstrap() - await tick() - continue - } - - const dirs = take(2) - if (dirs.length === 0) return - - await Promise.all(dirs.map((dir) => bootstrapInstance(dir))) - await tick() - } - } finally { - running = false - if (paused()) return - if (root || queued.size) schedule() - } + const sdkFor = (directory: string) => { + const cached = sdkCache.get(directory) + if (cached) return cached + const sdk = createOpencodeClient({ + baseUrl: globalSDK.url, + fetch: platform.fetch, + directory, + throwOnError: true, + }) + sdkCache.set(directory, sdk) + return sdk } createEffect(() => { @@ -280,196 +157,47 @@ function createGlobalSync() { createEffect(() => { if (globalStore.reload !== "complete") return setGlobalStore("reload", undefined) - refresh() + queue.refresh() }) - const children: Record, SetStoreFunction]> = {} - const booting = new Map>() - const sessionLoads = new Map>() - const sessionMeta = new Map() - - const sessionRecentWindow = 4 * 60 * 60 * 1000 - const sessionRecentLimit = 50 - - function sessionUpdatedAt(session: Session) { - return session.time.updated ?? session.time.created - } - - function compareSessionRecent(a: Session, b: Session) { - const aUpdated = sessionUpdatedAt(a) - const bUpdated = sessionUpdatedAt(b) - if (aUpdated !== bUpdated) return bUpdated - aUpdated - return cmp(a.id, b.id) - } - - function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) { - if (limit <= 0) return [] as Session[] - const selected: Session[] = [] - const seen = new Set() - for (const session of sessions) { - if (!session?.id) continue - if (seen.has(session.id)) continue - seen.add(session.id) - - if (sessionUpdatedAt(session) <= cutoff) continue - - const index = selected.findIndex((x) => compareSessionRecent(session, x) < 0) - if (index === -1) selected.push(session) - if (index !== -1) selected.splice(index, 0, session) - if (selected.length > limit) selected.pop() - } - return selected - } - - function trimSessions(input: Session[], options: { limit: number; permission: Record }) { - const limit = Math.max(0, options.limit) - const cutoff = Date.now() - sessionRecentWindow - const all = input - .filter((s) => !!s?.id) - .filter((s) => !s.time?.archived) - .sort((a, b) => cmp(a.id, b.id)) - - const roots = all.filter((s) => !s.parentID) - const children = all.filter((s) => !!s.parentID) - - const base = roots.slice(0, limit) - const recent = takeRecentSessions(roots.slice(limit), sessionRecentLimit, cutoff) - const keepRoots = [...base, ...recent] - - const keepRootIds = new Set(keepRoots.map((s) => s.id)) - const keepChildren = children.filter((s) => { - if (s.parentID && keepRootIds.has(s.parentID)) return true - const perms = options.permission[s.id] ?? [] - if (perms.length > 0) return true - return sessionUpdatedAt(s) > cutoff - }) - - return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id)) - } - - function ensureChild(directory: string) { - if (!directory) console.error("No directory provided") - if (!children[directory]) { - const vcs = runWithOwner(owner, () => - persisted( - Persist.workspace(directory, "vcs", ["vcs.v1"]), - createStore({ value: undefined as VcsInfo | undefined }), - ), - ) - if (!vcs) throw new Error("Failed to create persisted cache") - const vcsStore = vcs[0] - const vcsReady = vcs[3] - vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady }) - - const meta = runWithOwner(owner, () => - persisted( - Persist.workspace(directory, "project", ["project.v1"]), - createStore({ value: undefined as ProjectMeta | undefined }), - ), - ) - if (!meta) throw new Error("Failed to create persisted project metadata") - metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] }) - - const icon = runWithOwner(owner, () => - persisted( - Persist.workspace(directory, "icon", ["icon.v1"]), - createStore({ value: undefined as string | undefined }), - ), - ) - if (!icon) throw new Error("Failed to create persisted project icon") - iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] }) - - const init = () => { - const child = createStore({ - project: "", - projectMeta: meta[0].value, - icon: icon[0].value, - provider: { all: [], connected: [], default: {} }, - config: {}, - path: { state: "", config: "", worktree: "", directory: "", home: "" }, - status: "loading" as const, - agent: [], - command: [], - session: [], - sessionTotal: 0, - session_status: {}, - session_diff: {}, - todo: {}, - permission: {}, - question: {}, - mcp: {}, - lsp: [], - vcs: vcsStore.value, - limit: 5, - message: {}, - part: {}, - }) - - children[directory] = child - - createEffect(() => { - if (!vcsReady()) return - const cached = vcsStore.value - if (!cached?.branch) return - child[1]("vcs", (value) => value ?? cached) - }) - - createEffect(() => { - child[1]("projectMeta", meta[0].value) - }) - - createEffect(() => { - child[1]("icon", icon[0].value) - }) - } - - runWithOwner(owner, init) - } - const childStore = children[directory] - if (!childStore) throw new Error("Failed to create store") - return childStore - } - - function child(directory: string, options: ChildOptions = {}) { - const childStore = ensureChild(directory) - const shouldBootstrap = options.bootstrap ?? true - if (shouldBootstrap && childStore[0].status === "loading") { - void bootstrapInstance(directory) - } - return childStore - } - async function loadSessions(directory: string) { const pending = sessionLoads.get(directory) if (pending) return pending - const [store, setStore] = child(directory, { bootstrap: false }) + children.pin(directory) + const [store, setStore] = children.child(directory, { bootstrap: false }) const meta = sessionMeta.get(directory) if (meta && meta.limit >= store.limit) { const next = trimSessions(store.session, { limit: store.limit, permission: store.permission }) if (next.length !== store.session.length) { setStore("session", reconcile(next, { key: "id" })) } + children.unpin(directory) return } - const promise = globalSDK.client.session - .list({ directory, roots: true }) + const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT) + const promise = loadRootSessionsWithFallback({ + directory, + limit, + list: (query) => globalSDK.client.session.list(query), + onFallback: () => { + stats.loadSessionsFallback += 1 + updateStats(Object.keys(children.children).length) + }, + }) .then((x) => { const nonArchived = (x.data ?? []) .filter((s) => !!s?.id) .filter((s) => !s.time?.archived) - .sort((a, b) => cmp(a.id, b.id)) - - // Read the current limit at resolve-time so callers that bump the limit while - // a request is in-flight still get the expanded result. + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) const limit = store.limit - - const children = store.session.filter((s) => !!s.parentID) - const sessions = trimSessions([...nonArchived, ...children], { limit, permission: store.permission }) - - // Store total session count (used for "load more" pagination) - setStore("sessionTotal", nonArchived.length) + const childSessions = store.session.filter((s) => !!s.parentID) + const sessions = trimSessions([...nonArchived, ...childSessions], { limit, permission: store.permission }) + setStore( + "sessionTotal", + estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }), + ) setStore("session", reconcile(sessions, { key: "id" })) sessionMeta.set(directory, { limit }) }) @@ -482,6 +210,7 @@ function createGlobalSync() { sessionLoads.set(directory, promise) promise.finally(() => { sessionLoads.delete(directory) + children.unpin(directory) }) return promise } @@ -491,580 +220,99 @@ function createGlobalSync() { const pending = booting.get(directory) if (pending) return pending + children.pin(directory) const promise = (async () => { - const [store, setStore] = ensureChild(directory) - const cache = vcsCache.get(directory) + const child = children.ensureChild(directory) + const cache = children.vcsCache.get(directory) if (!cache) return - const meta = metaCache.get(directory) - if (!meta) return const sdk = sdkFor(directory) - - setStore("status", "loading") - - // projectMeta is synced from persisted storage in ensureChild. - // vcs is seeded from persisted storage in ensureChild. - - const blockingRequests = { - project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)), - provider: () => - sdk.provider.list().then((x) => { - setStore("provider", normalizeProviderList(x.data!)) - }), - agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), - config: () => sdk.config.get().then((x) => setStore("config", x.data!)), - } - - try { - await Promise.all(Object.values(blockingRequests).map((p) => retry(p))) - } catch (err) { - console.error("Failed to bootstrap instance", err) - const project = getFilename(directory) - const message = err instanceof Error ? err.message : String(err) - showToast({ title: `Failed to reload ${project}`, description: message }) - setStore("status", "partial") - return - } - - if (store.status !== "complete") setStore("status", "partial") - - Promise.all([ - sdk.path.get().then((x) => setStore("path", x.data!)), - sdk.command.list().then((x) => setStore("command", x.data ?? [])), - sdk.session.status().then((x) => setStore("session_status", x.data!)), - loadSessions(directory), - sdk.mcp.status().then((x) => setStore("mcp", x.data!)), - sdk.lsp.status().then((x) => setStore("lsp", x.data!)), - sdk.vcs.get().then((x) => { - const next = x.data ?? store.vcs - setStore("vcs", next) - if (next?.branch) cache.setStore("value", next) - }), - sdk.permission.list().then((x) => { - const grouped: Record = {} - for (const perm of x.data ?? []) { - if (!perm?.id || !perm.sessionID) continue - const existing = grouped[perm.sessionID] - if (existing) { - existing.push(perm) - continue - } - grouped[perm.sessionID] = [perm] - } - - batch(() => { - for (const sessionID of Object.keys(store.permission)) { - if (grouped[sessionID]) continue - setStore("permission", sessionID, []) - } - for (const [sessionID, permissions] of Object.entries(grouped)) { - setStore( - "permission", - sessionID, - reconcile( - permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }) - }), - sdk.question.list().then((x) => { - const grouped: Record = {} - for (const question of x.data ?? []) { - if (!question?.id || !question.sessionID) continue - const existing = grouped[question.sessionID] - if (existing) { - existing.push(question) - continue - } - grouped[question.sessionID] = [question] - } - - batch(() => { - for (const sessionID of Object.keys(store.question)) { - if (grouped[sessionID]) continue - setStore("question", sessionID, []) - } - for (const [sessionID, questions] of Object.entries(grouped)) { - setStore( - "question", - sessionID, - reconcile( - questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }) - }), - ]).then(() => { - setStore("status", "complete") + await bootstrapDirectory({ + directory, + sdk, + store: child[0], + setStore: child[1], + vcsCache: cache, + loadSessions, }) })() booting.set(directory, promise) promise.finally(() => { booting.delete(directory) + children.unpin(directory) }) return promise } - function purgeMessageParts(setStore: SetStoreFunction, messageID: string | undefined) { - if (!messageID) return - setStore( - produce((draft) => { - delete draft.part[messageID] - }), - ) - } - - function purgeSessionData(store: Store, setStore: SetStoreFunction, sessionID: string | undefined) { - if (!sessionID) return - - const messages = store.message[sessionID] - const messageIDs = (messages ?? []).map((m) => m.id).filter((id): id is string => !!id) - - setStore( - produce((draft) => { - delete draft.message[sessionID] - delete draft.session_diff[sessionID] - delete draft.todo[sessionID] - delete draft.permission[sessionID] - delete draft.question[sessionID] - delete draft.session_status[sessionID] - - for (const messageID of messageIDs) { - delete draft.part[messageID] - } - }), - ) - } - const unsub = globalSDK.event.listen((e) => { const directory = e.name const event = e.details if (directory === "global") { - switch (event?.type) { - case "global.disposed": { - refresh() - return - } - case "project.updated": { - const result = Binary.search(globalStore.project, event.properties.id, (s) => s.id) - if (result.found) { - setGlobalStore("project", result.index, reconcile(event.properties)) + applyGlobalEvent({ + event, + project: globalStore.project, + refresh: queue.refresh, + setGlobalProject(next) { + if (typeof next === "function") { + setGlobalStore("project", produce(next)) return } - setGlobalStore( - "project", - produce((draft) => { - draft.splice(result.index, 0, event.properties) - }), - ) - break - } - } + setGlobalStore("project", next) + }, + }) return } - const existing = children[directory] + const existing = children.children[directory] if (!existing) return - + children.mark(directory) const [store, setStore] = existing - - const cleanupSessionCaches = (sessionID: string) => { - if (!sessionID) return - - const hasAny = - store.message[sessionID] !== undefined || - store.session_diff[sessionID] !== undefined || - store.todo[sessionID] !== undefined || - store.permission[sessionID] !== undefined || - store.question[sessionID] !== undefined || - store.session_status[sessionID] !== undefined - - if (!hasAny) return - - setStore( - produce((draft) => { - const messages = draft.message[sessionID] - if (messages) { - for (const message of messages) { - const id = message?.id - if (!id) continue - delete draft.part[id] - } - } - - delete draft.message[sessionID] - delete draft.session_diff[sessionID] - delete draft.todo[sessionID] - delete draft.permission[sessionID] - delete draft.question[sessionID] - delete draft.session_status[sessionID] - }), - ) - } - - switch (event.type) { - case "server.instance.disposed": { - push(directory) - return - } - case "session.created": { - const info = event.properties.info - const result = Binary.search(store.session, info.id, (s) => s.id) - if (result.found) { - setStore("session", result.index, reconcile(info)) - break - } - const next = store.session.slice() - next.splice(result.index, 0, info) - const trimmed = trimSessions(next, { limit: store.limit, permission: store.permission }) - setStore("session", reconcile(trimmed, { key: "id" })) - if (!info.parentID) { - setStore("sessionTotal", (value) => value + 1) - } - break - } - case "session.updated": { - const info = event.properties.info - const result = Binary.search(store.session, info.id, (s) => s.id) - if (info.time.archived) { - if (result.found) { - setStore( - "session", - produce((draft) => { - draft.splice(result.index, 1) - }), - ) - } - cleanupSessionCaches(info.id) - if (info.parentID) break - setStore("sessionTotal", (value) => Math.max(0, value - 1)) - break - } - if (result.found) { - setStore("session", result.index, reconcile(info)) - break - } - const next = store.session.slice() - next.splice(result.index, 0, info) - const trimmed = trimSessions(next, { limit: store.limit, permission: store.permission }) - setStore("session", reconcile(trimmed, { key: "id" })) - break - } - case "session.deleted": { - const sessionID = event.properties.info.id - const result = Binary.search(store.session, sessionID, (s) => s.id) - if (result.found) { - setStore( - "session", - produce((draft) => { - draft.splice(result.index, 1) - }), - ) - } - cleanupSessionCaches(sessionID) - if (event.properties.info.parentID) break - setStore("sessionTotal", (value) => Math.max(0, value - 1)) - break - } - case "session.diff": - setStore("session_diff", event.properties.sessionID, reconcile(event.properties.diff, { key: "file" })) - break - case "todo.updated": - setStore("todo", event.properties.sessionID, reconcile(event.properties.todos, { key: "id" })) - break - case "session.status": { - setStore("session_status", event.properties.sessionID, reconcile(event.properties.status)) - break - } - case "message.updated": { - const messages = store.message[event.properties.info.sessionID] - if (!messages) { - setStore("message", event.properties.info.sessionID, [event.properties.info]) - break - } - const result = Binary.search(messages, event.properties.info.id, (m) => m.id) - if (result.found) { - setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info)) - break - } - setStore( - "message", - event.properties.info.sessionID, - produce((draft) => { - draft.splice(result.index, 0, event.properties.info) - }), - ) - break - } - case "message.removed": { - const sessionID = event.properties.sessionID - const messageID = event.properties.messageID - - setStore( - produce((draft) => { - const messages = draft.message[sessionID] - if (messages) { - const result = Binary.search(messages, messageID, (m) => m.id) - if (result.found) { - messages.splice(result.index, 1) - } - } - - delete draft.part[messageID] - }), - ) - break - } - case "message.part.updated": { - const part = event.properties.part - const parts = store.part[part.messageID] - if (!parts) { - setStore("part", part.messageID, [part]) - break - } - const result = Binary.search(parts, part.id, (p) => p.id) - if (result.found) { - setStore("part", part.messageID, result.index, reconcile(part)) - break - } - setStore( - "part", - part.messageID, - produce((draft) => { - draft.splice(result.index, 0, part) - }), - ) - break - } - case "message.part.delta": { - const parts = store.part[event.properties.messageID] - if (!parts) break - const result = Binary.search(parts, event.properties.partID, (p) => p.id) - if (!result.found) break - setStore( - "part", - event.properties.messageID, - produce((draft) => { - const part = draft[result.index] - const field = event.properties.field as keyof typeof part - const existing = part[field] as string | undefined - ;(part[field] as string) = (existing ?? "") + event.properties.delta - }), - ) - break - } - case "message.part.removed": { - const messageID = event.properties.messageID - const parts = store.part[messageID] - if (!parts) break - const result = Binary.search(parts, event.properties.partID, (p) => p.id) - if (result.found) { - setStore( - produce((draft) => { - const list = draft.part[messageID] - if (!list) return - const next = Binary.search(list, event.properties.partID, (p) => p.id) - if (!next.found) return - list.splice(next.index, 1) - if (list.length === 0) delete draft.part[messageID] - }), - ) - } - break - } - case "vcs.branch.updated": { - const next = { branch: event.properties.branch } - setStore("vcs", next) - const cache = vcsCache.get(directory) - if (cache) cache.setStore("value", next) - break - } - case "permission.asked": { - const sessionID = event.properties.sessionID - const permissions = store.permission[sessionID] - if (!permissions) { - setStore("permission", sessionID, [event.properties]) - break - } - - const result = Binary.search(permissions, event.properties.id, (p) => p.id) - if (result.found) { - setStore("permission", sessionID, result.index, reconcile(event.properties)) - break - } - - setStore( - "permission", - sessionID, - produce((draft) => { - draft.splice(result.index, 0, event.properties) - }), - ) - break - } - case "permission.replied": { - const permissions = store.permission[event.properties.sessionID] - if (!permissions) break - const result = Binary.search(permissions, event.properties.requestID, (p) => p.id) - if (!result.found) break - setStore( - "permission", - event.properties.sessionID, - produce((draft) => { - draft.splice(result.index, 1) - }), - ) - break - } - case "question.asked": { - const sessionID = event.properties.sessionID - const questions = store.question[sessionID] - if (!questions) { - setStore("question", sessionID, [event.properties]) - break - } - - const result = Binary.search(questions, event.properties.id, (q) => q.id) - if (result.found) { - setStore("question", sessionID, result.index, reconcile(event.properties)) - break - } - - setStore( - "question", - sessionID, - produce((draft) => { - draft.splice(result.index, 0, event.properties) - }), - ) - break - } - case "question.replied": - case "question.rejected": { - const questions = store.question[event.properties.sessionID] - if (!questions) break - const result = Binary.search(questions, event.properties.requestID, (q) => q.id) - if (!result.found) break - setStore( - "question", - event.properties.sessionID, - produce((draft) => { - draft.splice(result.index, 1) - }), - ) - break - } - case "lsp.updated": { + applyDirectoryEvent({ + event, + directory, + store, + setStore, + push: queue.push, + vcsCache: children.vcsCache.get(directory), + loadLsp: () => { sdkFor(directory) .lsp.status() .then((x) => setStore("lsp", x.data ?? [])) - break - } - } + }, + }) }) + onCleanup(unsub) onCleanup(() => { - if (!timer) return - clearTimeout(timer) + queue.dispose() + }) + onCleanup(() => { + for (const directory of Object.keys(children.children)) { + children.disposeDirectory(directory) + } }) async function bootstrap() { - const health = await globalSDK.client.global - .health() - .then((x) => x.data) - .catch(() => undefined) - if (!health?.healthy) { - showToast({ - variant: "error", - title: language.t("dialog.server.add.error"), - description: language.t("error.globalSync.connectFailed", { url: globalSDK.url }), - }) - setGlobalStore("ready", true) - return - } - - const tasks = [ - retry(() => - globalSDK.client.path.get().then((x) => { - setGlobalStore("path", x.data!) - }), - ), - retry(() => - globalSDK.client.global.config.get().then((x) => { - setGlobalStore("config", x.data!) - }), - ), - retry(() => - globalSDK.client.project.list().then(async (x) => { - const projects = (x.data ?? []) - .filter((p) => !!p?.id) - .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) - .slice() - .sort((a, b) => cmp(a.id, b.id)) - setGlobalStore("project", projects) - }), - ), - retry(() => - globalSDK.client.provider.list().then((x) => { - setGlobalStore("provider", normalizeProviderList(x.data!)) - }), - ), - retry(() => - globalSDK.client.provider.auth().then((x) => { - setGlobalStore("provider_auth", x.data ?? {}) - }), - ), - ] - - const results = await Promise.allSettled(tasks) - const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason) - - if (errors.length) { - const message = errors[0] instanceof Error ? errors[0].message : String(errors[0]) - const more = errors.length > 1 ? ` (+${errors.length - 1} more)` : "" - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: message + more, - }) - } - - setGlobalStore("ready", true) + await bootstrapGlobal({ + globalSDK: globalSDK.client, + connectErrorTitle: language.t("dialog.server.add.error"), + connectErrorDescription: language.t("error.globalSync.connectFailed", { url: globalSDK.url }), + requestFailedTitle: language.t("common.requestFailed"), + setGlobalStore, + }) } onMount(() => { - bootstrap() + void bootstrap() }) function projectMeta(directory: string, patch: ProjectMeta) { - const [store, setStore] = ensureChild(directory) - const cached = metaCache.get(directory) - if (!cached) return - const previous = store.projectMeta ?? {} - const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon - const commands = patch.commands ? { ...(previous.commands ?? {}), ...patch.commands } : previous.commands - const next = { - ...previous, - ...patch, - icon, - commands, - } - cached.setStore("value", next) - setStore("projectMeta", next) + children.projectMeta(directory, patch) } function projectIcon(directory: string, value: string | undefined) { - const [store, setStore] = ensureChild(directory) - const cached = iconCache.get(directory) - if (!cached) return - if (store.icon === value) return - cached.setStore("value", value) - setStore("icon", value) + children.projectIcon(directory, value) } return { @@ -1076,7 +324,7 @@ function createGlobalSync() { get error() { return globalStore.error }, - child, + child: children.child, bootstrap, updateConfig: (config: Config) => { setGlobalStore("reload", "pending") @@ -1112,3 +360,6 @@ export function useGlobalSync() { if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider") return context } + +export { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction" +export { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts new file mode 100644 index 0000000000..2137a19a82 --- /dev/null +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -0,0 +1,195 @@ +import { + type Config, + type Path, + type PermissionRequest, + type Project, + type ProviderAuthResponse, + type ProviderListResponse, + type QuestionRequest, + createOpencodeClient, +} from "@opencode-ai/sdk/v2/client" +import { batch } from "solid-js" +import { reconcile, type SetStoreFunction, type Store } from "solid-js/store" +import { retry } from "@opencode-ai/util/retry" +import { getFilename } from "@opencode-ai/util/path" +import { showToast } from "@opencode-ai/ui/toast" +import { cmp, normalizeProviderList } from "./utils" +import type { State, VcsCache } from "./types" + +type GlobalStore = { + ready: boolean + path: Path + project: Project[] + provider: ProviderListResponse + provider_auth: ProviderAuthResponse + config: Config + reload: undefined | "pending" | "complete" +} + +export async function bootstrapGlobal(input: { + globalSDK: ReturnType + connectErrorTitle: string + connectErrorDescription: string + requestFailedTitle: string + setGlobalStore: SetStoreFunction +}) { + const health = await input.globalSDK.global + .health() + .then((x) => x.data) + .catch(() => undefined) + if (!health?.healthy) { + showToast({ + variant: "error", + title: input.connectErrorTitle, + description: input.connectErrorDescription, + }) + input.setGlobalStore("ready", true) + return + } + + const tasks = [ + retry(() => + input.globalSDK.path.get().then((x) => { + input.setGlobalStore("path", x.data!) + }), + ), + retry(() => + input.globalSDK.global.config.get().then((x) => { + input.setGlobalStore("config", x.data!) + }), + ), + retry(() => + input.globalSDK.project.list().then((x) => { + const projects = (x.data ?? []) + .filter((p) => !!p?.id) + .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) + .slice() + .sort((a, b) => cmp(a.id, b.id)) + input.setGlobalStore("project", projects) + }), + ), + retry(() => + input.globalSDK.provider.list().then((x) => { + input.setGlobalStore("provider", normalizeProviderList(x.data!)) + }), + ), + retry(() => + input.globalSDK.provider.auth().then((x) => { + input.setGlobalStore("provider_auth", x.data ?? {}) + }), + ), + ] + + const results = await Promise.allSettled(tasks) + const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason) + if (errors.length) { + const message = errors[0] instanceof Error ? errors[0].message : String(errors[0]) + const more = errors.length > 1 ? ` (+${errors.length - 1} more)` : "" + showToast({ + variant: "error", + title: input.requestFailedTitle, + description: message + more, + }) + } + input.setGlobalStore("ready", true) +} + +function groupBySession(input: T[]) { + return input.reduce>((acc, item) => { + if (!item?.id || !item.sessionID) return acc + const list = acc[item.sessionID] + if (list) list.push(item) + if (!list) acc[item.sessionID] = [item] + return acc + }, {}) +} + +export async function bootstrapDirectory(input: { + directory: string + sdk: ReturnType + store: Store + setStore: SetStoreFunction + vcsCache: VcsCache + loadSessions: (directory: string) => Promise | void +}) { + input.setStore("status", "loading") + + const blockingRequests = { + project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)), + provider: () => + input.sdk.provider.list().then((x) => { + input.setStore("provider", normalizeProviderList(x.data!)) + }), + agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])), + config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)), + } + + try { + await Promise.all(Object.values(blockingRequests).map((p) => retry(p))) + } catch (err) { + console.error("Failed to bootstrap instance", err) + const project = getFilename(input.directory) + const message = err instanceof Error ? err.message : String(err) + showToast({ title: `Failed to reload ${project}`, description: message }) + input.setStore("status", "partial") + return + } + + if (input.store.status !== "complete") input.setStore("status", "partial") + + Promise.all([ + input.sdk.path.get().then((x) => input.setStore("path", x.data!)), + input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])), + input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)), + input.loadSessions(input.directory), + input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)), + input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)), + input.sdk.vcs.get().then((x) => { + const next = x.data ?? input.store.vcs + input.setStore("vcs", next) + if (next?.branch) input.vcsCache.setStore("value", next) + }), + input.sdk.permission.list().then((x) => { + const grouped = groupBySession( + (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), + ) + batch(() => { + for (const sessionID of Object.keys(input.store.permission)) { + if (grouped[sessionID]) continue + input.setStore("permission", sessionID, []) + } + for (const [sessionID, permissions] of Object.entries(grouped)) { + input.setStore( + "permission", + sessionID, + reconcile( + permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + input.sdk.question.list().then((x) => { + const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) + batch(() => { + for (const sessionID of Object.keys(input.store.question)) { + if (grouped[sessionID]) continue + input.setStore("question", sessionID, []) + } + for (const [sessionID, questions] of Object.entries(grouped)) { + input.setStore( + "question", + sessionID, + reconcile( + questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ]).then(() => { + input.setStore("status", "complete") + }) +} diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts new file mode 100644 index 0000000000..2feb7fe088 --- /dev/null +++ b/packages/app/src/context/global-sync/child-store.ts @@ -0,0 +1,263 @@ +import { createRoot, createEffect, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js" +import { createStore, type SetStoreFunction, type Store } from "solid-js/store" +import { Persist, persisted } from "@/utils/persist" +import type { VcsInfo } from "@opencode-ai/sdk/v2/client" +import { + DIR_IDLE_TTL_MS, + MAX_DIR_STORES, + type ChildOptions, + type DirState, + type IconCache, + type MetaCache, + type ProjectMeta, + type State, + type VcsCache, +} from "./types" +import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction" + +export function createChildStoreManager(input: { + owner: Owner + markStats: (activeDirectoryStores: number) => void + incrementEvictions: () => void + isBooting: (directory: string) => boolean + isLoadingSessions: (directory: string) => boolean + onBootstrap: (directory: string) => void + onDispose: (directory: string) => void +}) { + const children: Record, SetStoreFunction]> = {} + const vcsCache = new Map() + const metaCache = new Map() + const iconCache = new Map() + const lifecycle = new Map() + const pins = new Map() + const ownerPins = new WeakMap>() + const disposers = new Map void>() + + const mark = (directory: string) => { + if (!directory) return + lifecycle.set(directory, { lastAccessAt: Date.now() }) + runEviction() + } + + const pin = (directory: string) => { + if (!directory) return + pins.set(directory, (pins.get(directory) ?? 0) + 1) + mark(directory) + } + + const unpin = (directory: string) => { + if (!directory) return + const next = (pins.get(directory) ?? 0) - 1 + if (next > 0) { + pins.set(directory, next) + return + } + pins.delete(directory) + runEviction() + } + + const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0 + + const pinForOwner = (directory: string) => { + const current = getOwner() + if (!current) return + if (current === input.owner) return + const key = current as object + const set = ownerPins.get(key) + if (set?.has(directory)) return + if (set) set.add(directory) + if (!set) ownerPins.set(key, new Set([directory])) + pin(directory) + onCleanup(() => { + const set = ownerPins.get(key) + if (set) { + set.delete(directory) + if (set.size === 0) ownerPins.delete(key) + } + unpin(directory) + }) + } + + function disposeDirectory(directory: string) { + if ( + !canDisposeDirectory({ + directory, + hasStore: !!children[directory], + pinned: pinned(directory), + booting: input.isBooting(directory), + loadingSessions: input.isLoadingSessions(directory), + }) + ) { + return false + } + + vcsCache.delete(directory) + metaCache.delete(directory) + iconCache.delete(directory) + lifecycle.delete(directory) + const dispose = disposers.get(directory) + if (dispose) { + dispose() + disposers.delete(directory) + } + delete children[directory] + input.onDispose(directory) + input.markStats(Object.keys(children).length) + return true + } + + function runEviction() { + const stores = Object.keys(children) + if (stores.length === 0) return + const list = pickDirectoriesToEvict({ + stores, + state: lifecycle, + pins: new Set(stores.filter(pinned)), + max: MAX_DIR_STORES, + ttl: DIR_IDLE_TTL_MS, + now: Date.now(), + }) + if (list.length === 0) return + for (const directory of list) { + if (!disposeDirectory(directory)) continue + input.incrementEvictions() + } + } + + function ensureChild(directory: string) { + if (!directory) console.error("No directory provided") + if (!children[directory]) { + const vcs = runWithOwner(input.owner, () => + persisted( + Persist.workspace(directory, "vcs", ["vcs.v1"]), + createStore({ value: undefined as VcsInfo | undefined }), + ), + ) + if (!vcs) throw new Error("Failed to create persisted cache") + const vcsStore = vcs[0] + const vcsReady = vcs[3] + vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady }) + + const meta = runWithOwner(input.owner, () => + persisted( + Persist.workspace(directory, "project", ["project.v1"]), + createStore({ value: undefined as ProjectMeta | undefined }), + ), + ) + if (!meta) throw new Error("Failed to create persisted project metadata") + metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] }) + + const icon = runWithOwner(input.owner, () => + persisted( + Persist.workspace(directory, "icon", ["icon.v1"]), + createStore({ value: undefined as string | undefined }), + ), + ) + if (!icon) throw new Error("Failed to create persisted project icon") + iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] }) + + const init = () => + createRoot((dispose) => { + const child = createStore({ + project: "", + projectMeta: meta[0].value, + icon: icon[0].value, + provider: { all: [], connected: [], default: {} }, + config: {}, + path: { state: "", config: "", worktree: "", directory: "", home: "" }, + status: "loading" as const, + agent: [], + command: [], + session: [], + sessionTotal: 0, + session_status: {}, + session_diff: {}, + todo: {}, + permission: {}, + question: {}, + mcp: {}, + lsp: [], + vcs: vcsStore.value, + limit: 5, + message: {}, + part: {}, + }) + children[directory] = child + disposers.set(directory, dispose) + + createEffect(() => { + if (!vcsReady()) return + const cached = vcsStore.value + if (!cached?.branch) return + child[1]("vcs", (value) => value ?? cached) + }) + createEffect(() => { + child[1]("projectMeta", meta[0].value) + }) + createEffect(() => { + child[1]("icon", icon[0].value) + }) + }) + + runWithOwner(input.owner, init) + input.markStats(Object.keys(children).length) + } + mark(directory) + const childStore = children[directory] + if (!childStore) throw new Error("Failed to create store") + return childStore + } + + function child(directory: string, options: ChildOptions = {}) { + const childStore = ensureChild(directory) + pinForOwner(directory) + const shouldBootstrap = options.bootstrap ?? true + if (shouldBootstrap && childStore[0].status === "loading") { + input.onBootstrap(directory) + } + return childStore + } + + function projectMeta(directory: string, patch: ProjectMeta) { + const [store, setStore] = ensureChild(directory) + const cached = metaCache.get(directory) + if (!cached) return + const previous = store.projectMeta ?? {} + const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon + const commands = patch.commands ? { ...(previous.commands ?? {}), ...patch.commands } : previous.commands + const next = { + ...previous, + ...patch, + icon, + commands, + } + cached.setStore("value", next) + setStore("projectMeta", next) + } + + function projectIcon(directory: string, value: string | undefined) { + const [store, setStore] = ensureChild(directory) + const cached = iconCache.get(directory) + if (!cached) return + if (store.icon === value) return + cached.setStore("value", value) + setStore("icon", value) + } + + return { + children, + ensureChild, + child, + projectMeta, + projectIcon, + mark, + pin, + unpin, + pinned, + disposeDirectory, + runEviction, + vcsCache, + metaCache, + iconCache, + } +} diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts new file mode 100644 index 0000000000..f79b9fc958 --- /dev/null +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, test } from "bun:test" +import type { Message, Part, Project, Session } from "@opencode-ai/sdk/v2/client" +import { createStore } from "solid-js/store" +import type { State } from "./types" +import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer" + +const rootSession = (input: { id: string; parentID?: string; archived?: number }) => + ({ + id: input.id, + parentID: input.parentID, + time: { + created: 1, + updated: 1, + archived: input.archived, + }, + }) as Session + +const userMessage = (id: string, sessionID: string) => + ({ + id, + sessionID, + role: "user", + time: { created: 1 }, + agent: "assistant", + model: { providerID: "openai", modelID: "gpt" }, + }) as Message + +const textPart = (id: string, sessionID: string, messageID: string) => + ({ + id, + sessionID, + messageID, + type: "text", + text: id, + }) as Part + +const baseState = (input: Partial = {}) => + ({ + status: "complete", + agent: [], + command: [], + project: "", + projectMeta: undefined, + icon: undefined, + provider: {} as State["provider"], + config: {} as State["config"], + path: { directory: "/tmp" } as State["path"], + session: [], + sessionTotal: 0, + session_status: {}, + session_diff: {}, + todo: {}, + permission: {}, + question: {}, + mcp: {}, + lsp: [], + vcs: undefined, + limit: 10, + message: {}, + part: {}, + ...input, + }) as State + +describe("applyGlobalEvent", () => { + test("upserts project.updated in sorted position", () => { + const project = [{ id: "a" }, { id: "c" }] as Project[] + let refreshCount = 0 + applyGlobalEvent({ + event: { type: "project.updated", properties: { id: "b" } }, + project, + refresh: () => { + refreshCount += 1 + }, + setGlobalProject(next) { + if (typeof next === "function") next(project) + }, + }) + + expect(project.map((x) => x.id)).toEqual(["a", "b", "c"]) + expect(refreshCount).toBe(0) + }) + + test("handles global.disposed by triggering refresh", () => { + let refreshCount = 0 + applyGlobalEvent({ + event: { type: "global.disposed" }, + project: [], + refresh: () => { + refreshCount += 1 + }, + setGlobalProject() {}, + }) + + expect(refreshCount).toBe(1) + }) +}) + +describe("applyDirectoryEvent", () => { + test("inserts root sessions in sorted order and updates sessionTotal", () => { + const [store, setStore] = createStore( + baseState({ + session: [rootSession({ id: "b" })], + sessionTotal: 1, + }), + ) + + applyDirectoryEvent({ + event: { type: "session.created", properties: { info: rootSession({ id: "a" }) } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.session.map((x) => x.id)).toEqual(["a", "b"]) + expect(store.sessionTotal).toBe(2) + + applyDirectoryEvent({ + event: { type: "session.created", properties: { info: rootSession({ id: "c", parentID: "a" }) } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.sessionTotal).toBe(2) + }) + + test("cleans session caches when archived", () => { + const message = userMessage("msg_1", "ses_1") + const [store, setStore] = createStore( + baseState({ + session: [rootSession({ id: "ses_1" }), rootSession({ id: "ses_2" })], + sessionTotal: 2, + message: { ses_1: [message] }, + part: { [message.id]: [textPart("prt_1", "ses_1", message.id)] }, + session_diff: { ses_1: [] }, + todo: { ses_1: [] }, + permission: { ses_1: [] }, + question: { ses_1: [] }, + session_status: { ses_1: { type: "busy" } }, + }), + ) + + applyDirectoryEvent({ + event: { type: "session.updated", properties: { info: rootSession({ id: "ses_1", archived: 10 }) } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.session.map((x) => x.id)).toEqual(["ses_2"]) + expect(store.sessionTotal).toBe(1) + expect(store.message.ses_1).toBeUndefined() + expect(store.part[message.id]).toBeUndefined() + expect(store.session_diff.ses_1).toBeUndefined() + expect(store.todo.ses_1).toBeUndefined() + expect(store.permission.ses_1).toBeUndefined() + expect(store.question.ses_1).toBeUndefined() + expect(store.session_status.ses_1).toBeUndefined() + }) + + test("routes disposal and lsp events to side-effect handlers", () => { + const [store, setStore] = createStore(baseState()) + const pushes: string[] = [] + let lspLoads = 0 + + applyDirectoryEvent({ + event: { type: "server.instance.disposed" }, + store, + setStore, + push(directory) { + pushes.push(directory) + }, + directory: "/tmp", + loadLsp() { + lspLoads += 1 + }, + }) + + applyDirectoryEvent({ + event: { type: "lsp.updated" }, + store, + setStore, + push(directory) { + pushes.push(directory) + }, + directory: "/tmp", + loadLsp() { + lspLoads += 1 + }, + }) + + expect(pushes).toEqual(["/tmp"]) + expect(lspLoads).toBe(1) + }) +}) diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts new file mode 100644 index 0000000000..d6bfefbe5f --- /dev/null +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -0,0 +1,337 @@ +import { Binary } from "@opencode-ai/util/binary" +import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" +import type { + FileDiff, + Message, + Part, + PermissionRequest, + Project, + QuestionRequest, + Session, + SessionStatus, + Todo, +} from "@opencode-ai/sdk/v2/client" +import type { State, VcsCache } from "./types" +import { trimSessions } from "./session-trim" + +export function applyGlobalEvent(input: { + event: { type: string; properties?: unknown } + project: Project[] + setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void + refresh: () => void +}) { + if (input.event.type === "global.disposed") { + input.refresh() + return + } + + if (input.event.type !== "project.updated") return + const properties = input.event.properties as Project + const result = Binary.search(input.project, properties.id, (s) => s.id) + if (result.found) { + input.setGlobalProject((draft) => { + draft[result.index] = { ...draft[result.index], ...properties } + }) + return + } + input.setGlobalProject((draft) => { + draft.splice(result.index, 0, properties) + }) +} + +function cleanupSessionCaches(store: Store, setStore: SetStoreFunction, sessionID: string) { + if (!sessionID) return + const hasAny = + store.message[sessionID] !== undefined || + store.session_diff[sessionID] !== undefined || + store.todo[sessionID] !== undefined || + store.permission[sessionID] !== undefined || + store.question[sessionID] !== undefined || + store.session_status[sessionID] !== undefined + if (!hasAny) return + setStore( + produce((draft) => { + const messages = draft.message[sessionID] + if (messages) { + for (const message of messages) { + const id = message?.id + if (!id) continue + delete draft.part[id] + } + } + delete draft.message[sessionID] + delete draft.session_diff[sessionID] + delete draft.todo[sessionID] + delete draft.permission[sessionID] + delete draft.question[sessionID] + delete draft.session_status[sessionID] + }), + ) +} + +export function applyDirectoryEvent(input: { + event: { type: string; properties?: unknown } + store: Store + setStore: SetStoreFunction + push: (directory: string) => void + directory: string + loadLsp: () => void + vcsCache?: VcsCache +}) { + const event = input.event + switch (event.type) { + case "server.instance.disposed": { + input.push(input.directory) + return + } + case "session.created": { + const info = (event.properties as { info: Session }).info + const result = Binary.search(input.store.session, info.id, (s) => s.id) + if (result.found) { + input.setStore("session", result.index, reconcile(info)) + break + } + const next = input.store.session.slice() + next.splice(result.index, 0, info) + const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission }) + input.setStore("session", reconcile(trimmed, { key: "id" })) + if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1) + break + } + case "session.updated": { + const info = (event.properties as { info: Session }).info + const result = Binary.search(input.store.session, info.id, (s) => s.id) + if (info.time.archived) { + if (result.found) { + input.setStore( + "session", + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + cleanupSessionCaches(input.store, input.setStore, info.id) + if (info.parentID) break + input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) + break + } + if (result.found) { + input.setStore("session", result.index, reconcile(info)) + break + } + const next = input.store.session.slice() + next.splice(result.index, 0, info) + const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission }) + input.setStore("session", reconcile(trimmed, { key: "id" })) + break + } + case "session.deleted": { + const info = (event.properties as { info: Session }).info + const result = Binary.search(input.store.session, info.id, (s) => s.id) + if (result.found) { + input.setStore( + "session", + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + cleanupSessionCaches(input.store, input.setStore, info.id) + if (info.parentID) break + input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) + break + } + case "session.diff": { + const props = event.properties as { sessionID: string; diff: FileDiff[] } + input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" })) + break + } + case "todo.updated": { + const props = event.properties as { sessionID: string; todos: Todo[] } + input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" })) + break + } + case "session.status": { + const props = event.properties as { sessionID: string; status: SessionStatus } + input.setStore("session_status", props.sessionID, reconcile(props.status)) + break + } + case "message.updated": { + const info = (event.properties as { info: Message }).info + const messages = input.store.message[info.sessionID] + if (!messages) { + input.setStore("message", info.sessionID, [info]) + break + } + const result = Binary.search(messages, info.id, (m) => m.id) + if (result.found) { + input.setStore("message", info.sessionID, result.index, reconcile(info)) + break + } + input.setStore( + "message", + info.sessionID, + produce((draft) => { + draft.splice(result.index, 0, info) + }), + ) + break + } + case "message.removed": { + const props = event.properties as { sessionID: string; messageID: string } + input.setStore( + produce((draft) => { + const messages = draft.message[props.sessionID] + if (messages) { + const result = Binary.search(messages, props.messageID, (m) => m.id) + if (result.found) messages.splice(result.index, 1) + } + delete draft.part[props.messageID] + }), + ) + break + } + case "message.part.updated": { + const part = (event.properties as { part: Part }).part + const parts = input.store.part[part.messageID] + if (!parts) { + input.setStore("part", part.messageID, [part]) + break + } + const result = Binary.search(parts, part.id, (p) => p.id) + if (result.found) { + input.setStore("part", part.messageID, result.index, reconcile(part)) + break + } + input.setStore( + "part", + part.messageID, + produce((draft) => { + draft.splice(result.index, 0, part) + }), + ) + break + } + case "message.part.removed": { + const props = event.properties as { messageID: string; partID: string } + const parts = input.store.part[props.messageID] + if (!parts) break + const result = Binary.search(parts, props.partID, (p) => p.id) + if (result.found) { + input.setStore( + produce((draft) => { + const list = draft.part[props.messageID] + if (!list) return + const next = Binary.search(list, props.partID, (p) => p.id) + if (!next.found) return + list.splice(next.index, 1) + if (list.length === 0) delete draft.part[props.messageID] + }), + ) + } + break + } + case "message.part.delta": { + const props = event.properties as { messageID: string; partID: string; field: string; delta: string } + const parts = input.store.part[props.messageID] + if (!parts) break + const result = Binary.search(parts, props.partID, (p) => p.id) + if (!result.found) break + input.setStore( + "part", + props.messageID, + produce((draft) => { + const part = draft[result.index] + const field = props.field as keyof typeof part + const existing = part[field] as string | undefined + ;(part[field] as string) = (existing ?? "") + props.delta + }), + ) + break + } + case "vcs.branch.updated": { + const props = event.properties as { branch: string } + const next = { branch: props.branch } + input.setStore("vcs", next) + if (input.vcsCache) input.vcsCache.setStore("value", next) + break + } + case "permission.asked": { + const permission = event.properties as PermissionRequest + const permissions = input.store.permission[permission.sessionID] + if (!permissions) { + input.setStore("permission", permission.sessionID, [permission]) + break + } + const result = Binary.search(permissions, permission.id, (p) => p.id) + if (result.found) { + input.setStore("permission", permission.sessionID, result.index, reconcile(permission)) + break + } + input.setStore( + "permission", + permission.sessionID, + produce((draft) => { + draft.splice(result.index, 0, permission) + }), + ) + break + } + case "permission.replied": { + const props = event.properties as { sessionID: string; requestID: string } + const permissions = input.store.permission[props.sessionID] + if (!permissions) break + const result = Binary.search(permissions, props.requestID, (p) => p.id) + if (!result.found) break + input.setStore( + "permission", + props.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + break + } + case "question.asked": { + const question = event.properties as QuestionRequest + const questions = input.store.question[question.sessionID] + if (!questions) { + input.setStore("question", question.sessionID, [question]) + break + } + const result = Binary.search(questions, question.id, (q) => q.id) + if (result.found) { + input.setStore("question", question.sessionID, result.index, reconcile(question)) + break + } + input.setStore( + "question", + question.sessionID, + produce((draft) => { + draft.splice(result.index, 0, question) + }), + ) + break + } + case "question.replied": + case "question.rejected": { + const props = event.properties as { sessionID: string; requestID: string } + const questions = input.store.question[props.sessionID] + if (!questions) break + const result = Binary.search(questions, props.requestID, (q) => q.id) + if (!result.found) break + input.setStore( + "question", + props.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + break + } + case "lsp.updated": { + input.loadLsp() + break + } + } +} diff --git a/packages/app/src/context/global-sync/eviction.ts b/packages/app/src/context/global-sync/eviction.ts new file mode 100644 index 0000000000..676a6ee17e --- /dev/null +++ b/packages/app/src/context/global-sync/eviction.ts @@ -0,0 +1,28 @@ +import type { DisposeCheck, EvictPlan } from "./types" + +export function pickDirectoriesToEvict(input: EvictPlan) { + const overflow = Math.max(0, input.stores.length - input.max) + let pendingOverflow = overflow + const sorted = input.stores + .filter((dir) => !input.pins.has(dir)) + .slice() + .sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0)) + const output: string[] = [] + for (const dir of sorted) { + const last = input.state.get(dir)?.lastAccessAt ?? 0 + const idle = input.now - last >= input.ttl + if (!idle && pendingOverflow <= 0) continue + output.push(dir) + if (pendingOverflow > 0) pendingOverflow -= 1 + } + return output +} + +export function canDisposeDirectory(input: DisposeCheck) { + if (!input.directory) return false + if (!input.hasStore) return false + if (input.pinned) return false + if (input.booting) return false + if (input.loadingSessions) return false + return true +} diff --git a/packages/app/src/context/global-sync/queue.ts b/packages/app/src/context/global-sync/queue.ts new file mode 100644 index 0000000000..c3468583b9 --- /dev/null +++ b/packages/app/src/context/global-sync/queue.ts @@ -0,0 +1,83 @@ +type QueueInput = { + paused: () => boolean + bootstrap: () => Promise + bootstrapInstance: (directory: string) => Promise | void +} + +export function createRefreshQueue(input: QueueInput) { + const queued = new Set() + let root = false + let running = false + let timer: ReturnType | undefined + + const tick = () => new Promise((resolve) => setTimeout(resolve, 0)) + + const take = (count: number) => { + if (queued.size === 0) return [] as string[] + const items: string[] = [] + for (const item of queued) { + queued.delete(item) + items.push(item) + if (items.length >= count) break + } + return items + } + + const schedule = () => { + if (timer) return + timer = setTimeout(() => { + timer = undefined + void drain() + }, 0) + } + + const push = (directory: string) => { + if (!directory) return + queued.add(directory) + if (input.paused()) return + schedule() + } + + const refresh = () => { + root = true + if (input.paused()) return + schedule() + } + + async function drain() { + if (running) return + running = true + try { + while (true) { + if (input.paused()) return + if (root) { + root = false + await input.bootstrap() + await tick() + continue + } + const dirs = take(2) + if (dirs.length === 0) return + await Promise.all(dirs.map((dir) => input.bootstrapInstance(dir))) + await tick() + } + } finally { + running = false + if (input.paused()) return + if (root || queued.size) schedule() + } + } + + return { + push, + refresh, + clear(directory: string) { + queued.delete(directory) + }, + dispose() { + if (!timer) return + clearTimeout(timer) + timer = undefined + }, + } +} diff --git a/packages/app/src/context/global-sync/session-load.ts b/packages/app/src/context/global-sync/session-load.ts new file mode 100644 index 0000000000..443aa84502 --- /dev/null +++ b/packages/app/src/context/global-sync/session-load.ts @@ -0,0 +1,26 @@ +import type { RootLoadArgs } from "./types" + +export async function loadRootSessionsWithFallback(input: RootLoadArgs) { + try { + const result = await input.list({ directory: input.directory, roots: true, limit: input.limit }) + return { + data: result.data, + limit: input.limit, + limited: true, + } as const + } catch { + input.onFallback() + const result = await input.list({ directory: input.directory, roots: true }) + return { + data: result.data, + limit: input.limit, + limited: false, + } as const + } +} + +export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) { + if (!input.limited) return input.count + if (input.count < input.limit) return input.count + return input.count + 1 +} diff --git a/packages/app/src/context/global-sync/session-trim.test.ts b/packages/app/src/context/global-sync/session-trim.test.ts new file mode 100644 index 0000000000..be12c074b5 --- /dev/null +++ b/packages/app/src/context/global-sync/session-trim.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test" +import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" +import { trimSessions } from "./session-trim" + +const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) => + ({ + id: input.id, + parentID: input.parentID, + time: { + created: input.created, + updated: input.updated, + archived: input.archived, + }, + }) as Session + +describe("trimSessions", () => { + test("keeps base roots and recent roots beyond the limit", () => { + const now = 1_000_000 + const list = [ + session({ id: "a", created: now - 100_000 }), + session({ id: "b", created: now - 90_000 }), + session({ id: "c", created: now - 80_000 }), + session({ id: "d", created: now - 70_000, updated: now - 1_000 }), + session({ id: "e", created: now - 60_000, archived: now - 10 }), + ] + + const result = trimSessions(list, { limit: 2, permission: {}, now }) + expect(result.map((x) => x.id)).toEqual(["a", "b", "c", "d"]) + }) + + test("keeps children when root is kept, permission exists, or child is recent", () => { + const now = 1_000_000 + const list = [ + session({ id: "root-1", created: now - 1000 }), + session({ id: "root-2", created: now - 2000 }), + session({ id: "z-root", created: now - 30_000_000 }), + session({ id: "child-kept-by-root", parentID: "root-1", created: now - 20_000_000 }), + session({ id: "child-kept-by-permission", parentID: "z-root", created: now - 20_000_000 }), + session({ id: "child-kept-by-recency", parentID: "z-root", created: now - 500 }), + session({ id: "child-trimmed", parentID: "z-root", created: now - 20_000_000 }), + ] + + const result = trimSessions(list, { + limit: 2, + permission: { + "child-kept-by-permission": [{ id: "perm-1" } as PermissionRequest], + }, + now, + }) + + expect(result.map((x) => x.id)).toEqual([ + "child-kept-by-permission", + "child-kept-by-recency", + "child-kept-by-root", + "root-1", + "root-2", + ]) + }) +}) diff --git a/packages/app/src/context/global-sync/session-trim.ts b/packages/app/src/context/global-sync/session-trim.ts new file mode 100644 index 0000000000..800ba74a68 --- /dev/null +++ b/packages/app/src/context/global-sync/session-trim.ts @@ -0,0 +1,56 @@ +import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" +import { cmp } from "./utils" +import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types" + +export function sessionUpdatedAt(session: Session) { + return session.time.updated ?? session.time.created +} + +export function compareSessionRecent(a: Session, b: Session) { + const aUpdated = sessionUpdatedAt(a) + const bUpdated = sessionUpdatedAt(b) + if (aUpdated !== bUpdated) return bUpdated - aUpdated + return cmp(a.id, b.id) +} + +export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) { + if (limit <= 0) return [] as Session[] + const selected: Session[] = [] + const seen = new Set() + for (const session of sessions) { + if (!session?.id) continue + if (seen.has(session.id)) continue + seen.add(session.id) + if (sessionUpdatedAt(session) <= cutoff) continue + const index = selected.findIndex((x) => compareSessionRecent(session, x) < 0) + if (index === -1) selected.push(session) + if (index !== -1) selected.splice(index, 0, session) + if (selected.length > limit) selected.pop() + } + return selected +} + +export function trimSessions( + input: Session[], + options: { limit: number; permission: Record; now?: number }, +) { + const limit = Math.max(0, options.limit) + const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW + const all = input + .filter((s) => !!s?.id) + .filter((s) => !s.time?.archived) + .sort((a, b) => cmp(a.id, b.id)) + const roots = all.filter((s) => !s.parentID) + const children = all.filter((s) => !!s.parentID) + const base = roots.slice(0, limit) + const recent = takeRecentSessions(roots.slice(limit), SESSION_RECENT_LIMIT, cutoff) + const keepRoots = [...base, ...recent] + const keepRootIds = new Set(keepRoots.map((s) => s.id)) + const keepChildren = children.filter((s) => { + if (s.parentID && keepRootIds.has(s.parentID)) return true + const perms = options.permission[s.id] ?? [] + if (perms.length > 0) return true + return sessionUpdatedAt(s) > cutoff + }) + return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id)) +} diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts new file mode 100644 index 0000000000..ade0b973a2 --- /dev/null +++ b/packages/app/src/context/global-sync/types.ts @@ -0,0 +1,134 @@ +import type { + Agent, + Command, + Config, + FileDiff, + LspStatus, + McpStatus, + Message, + Part, + Path, + PermissionRequest, + Project, + ProviderListResponse, + QuestionRequest, + Session, + SessionStatus, + Todo, + VcsInfo, +} from "@opencode-ai/sdk/v2/client" +import type { Accessor } from "solid-js" +import type { SetStoreFunction, Store } from "solid-js/store" + +export type ProjectMeta = { + name?: string + icon?: { + override?: string + color?: string + } + commands?: { + start?: string + } +} + +export type State = { + status: "loading" | "partial" | "complete" + agent: Agent[] + command: Command[] + project: string + projectMeta: ProjectMeta | undefined + icon: string | undefined + provider: ProviderListResponse + config: Config + path: Path + session: Session[] + sessionTotal: number + session_status: { + [sessionID: string]: SessionStatus + } + session_diff: { + [sessionID: string]: FileDiff[] + } + todo: { + [sessionID: string]: Todo[] + } + permission: { + [sessionID: string]: PermissionRequest[] + } + question: { + [sessionID: string]: QuestionRequest[] + } + mcp: { + [name: string]: McpStatus + } + lsp: LspStatus[] + vcs: VcsInfo | undefined + limit: number + message: { + [sessionID: string]: Message[] + } + part: { + [messageID: string]: Part[] + } +} + +export type VcsCache = { + store: Store<{ value: VcsInfo | undefined }> + setStore: SetStoreFunction<{ value: VcsInfo | undefined }> + ready: Accessor +} + +export type MetaCache = { + store: Store<{ value: ProjectMeta | undefined }> + setStore: SetStoreFunction<{ value: ProjectMeta | undefined }> + ready: Accessor +} + +export type IconCache = { + store: Store<{ value: string | undefined }> + setStore: SetStoreFunction<{ value: string | undefined }> + ready: Accessor +} + +export type ChildOptions = { + bootstrap?: boolean +} + +export type DirState = { + lastAccessAt: number +} + +export type EvictPlan = { + stores: string[] + state: Map + pins: Set + max: number + ttl: number + now: number +} + +export type DisposeCheck = { + directory: string + hasStore: boolean + pinned: boolean + booting: boolean + loadingSessions: boolean +} + +export type RootLoadArgs = { + directory: string + limit: number + list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }> + onFallback: () => void +} + +export type RootLoadResult = { + data?: Session[] + limit: number + limited: boolean +} + +export const MAX_DIR_STORES = 30 +export const DIR_IDLE_TTL_MS = 20 * 60 * 1000 +export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000 +export const SESSION_RECENT_LIMIT = 50 diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts new file mode 100644 index 0000000000..6b78134a61 --- /dev/null +++ b/packages/app/src/context/global-sync/utils.ts @@ -0,0 +1,25 @@ +import type { Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client" + +export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + +export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse { + return { + ...input, + all: input.all.map((provider) => ({ + ...provider, + models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")), + })), + } +} + +export function sanitizeProject(project: Project) { + if (!project.icon?.url && !project.icon?.override) return project + return { + ...project, + icon: { + ...project.icon, + url: undefined, + override: undefined, + }, + } +} diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index bf081996b0..22f7bcca1e 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -76,6 +76,26 @@ const LOCALES: readonly Locale[] = [ "th", ] +type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen" +const PARITY_CHECK: Record, Record> = { + zh, + zht, + ko, + de, + es, + fr, + da, + ja, + pl, + ru, + ar, + no, + br, + th, + bs, +} +void PARITY_CHECK + function detectLocale(): Locale { if (typeof navigator !== "object") return "en" diff --git a/packages/app/src/context/layout-scroll.test.ts b/packages/app/src/context/layout-scroll.test.ts index c565653850..c421a58b67 100644 --- a/packages/app/src/context/layout-scroll.test.ts +++ b/packages/app/src/context/layout-scroll.test.ts @@ -1,73 +1,36 @@ import { describe, expect, test } from "bun:test" -import { createRoot } from "solid-js" -import { createStore } from "solid-js/store" -import { makePersisted, type SyncStorage } from "@solid-primitives/storage" import { createScrollPersistence } from "./layout-scroll" describe("createScrollPersistence", () => { - test.skip("debounces persisted scroll writes", async () => { - const key = "layout-scroll.test" - const data = new Map() - const writes: string[] = [] - const stats = { flushes: 0 } - - const storage = { - getItem: (k: string) => data.get(k) ?? null, - setItem: (k: string, v: string) => { - data.set(k, v) - if (k === key) writes.push(v) + test("debounces persisted scroll writes", async () => { + const snapshot = { + session: { + review: { x: 0, y: 0 }, }, - removeItem: (k: string) => { - data.delete(k) + } as Record> + const writes: Array> = [] + const scroll = createScrollPersistence({ + debounceMs: 10, + getSnapshot: (sessionKey) => snapshot[sessionKey], + onFlush: (sessionKey, next) => { + snapshot[sessionKey] = next + writes.push(next) }, - } as SyncStorage - - await new Promise((resolve, reject) => { - createRoot((dispose) => { - const [raw, setRaw] = createStore({ - sessionView: {} as Record }>, - }) - - const [store, setStore] = makePersisted([raw, setRaw], { name: key, storage }) - - const scroll = createScrollPersistence({ - debounceMs: 30, - getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll, - onFlush: (sessionKey, next) => { - stats.flushes += 1 - - const current = store.sessionView[sessionKey] - if (!current) { - setStore("sessionView", sessionKey, { scroll: next }) - return - } - setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next })) - }, - }) - - const run = async () => { - await new Promise((r) => setTimeout(r, 0)) - writes.length = 0 - - for (const i of Array.from({ length: 100 }, (_, n) => n)) { - scroll.setScroll("session", "review", { x: 0, y: i }) - } - - await new Promise((r) => setTimeout(r, 120)) - - expect(stats.flushes).toBeGreaterThanOrEqual(1) - expect(writes.length).toBeGreaterThanOrEqual(1) - expect(writes.length).toBeLessThanOrEqual(2) - } - - void run() - .then(resolve) - .catch(reject) - .finally(() => { - scroll.dispose() - dispose() - }) - }) }) + + for (const i of Array.from({ length: 30 }, (_, n) => n + 1)) { + scroll.setScroll("session", "review", { x: 0, y: i }) + } + + await new Promise((resolve) => setTimeout(resolve, 40)) + + expect(writes).toHaveLength(1) + expect(writes[0]?.review).toEqual({ x: 0, y: 30 }) + + scroll.setScroll("session", "review", { x: 0, y: 30 }) + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(writes).toHaveLength(1) + scroll.dispose() }) }) diff --git a/packages/app/src/context/layout.test.ts b/packages/app/src/context/layout.test.ts new file mode 100644 index 0000000000..582d5edbd2 --- /dev/null +++ b/packages/app/src/context/layout.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test" +import { createRoot, createSignal } from "solid-js" +import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout" + +describe("layout session-key helpers", () => { + test("couples touch and scroll seed in order", () => { + const calls: string[] = [] + const result = ensureSessionKey( + "dir/a", + (key) => calls.push(`touch:${key}`), + (key) => calls.push(`seed:${key}`), + ) + + expect(result).toBe("dir/a") + expect(calls).toEqual(["touch:dir/a", "seed:dir/a"]) + }) + + test("reads dynamic accessor keys lazily", () => { + const seen: string[] = [] + + createRoot((dispose) => { + const [key, setKey] = createSignal("dir/one") + const read = createSessionKeyReader(key, (value) => seen.push(value)) + + expect(read()).toBe("dir/one") + setKey("dir/two") + expect(read()).toBe("dir/two") + + dispose() + }) + + expect(seen).toEqual(["dir/one", "dir/two"]) + }) +}) + +describe("pruneSessionKeys", () => { + test("keeps active key and drops lowest-used keys", () => { + const drop = pruneSessionKeys({ + keep: "k4", + max: 3, + used: new Map([ + ["k1", 1], + ["k2", 2], + ["k3", 3], + ["k4", 4], + ]), + view: ["k1", "k2", "k4"], + tabs: ["k1", "k3", "k4"], + }) + + expect(drop).toEqual(["k1"]) + expect(drop.includes("k4")).toBe(false) + }) + + test("does not prune without keep key", () => { + const drop = pruneSessionKeys({ + keep: undefined, + max: 1, + used: new Map([ + ["k1", 1], + ["k2", 2], + ]), + view: ["k1"], + tabs: ["k2"], + }) + + expect(drop).toEqual([]) + }) +}) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 95a2006ea9..8d9c865f84 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -1,5 +1,5 @@ import { createStore, produce } from "solid-js/store" -import { batch, createEffect, createMemo, on, onCleanup, onMount, type Accessor } from "solid-js" +import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { useGlobalSync } from "./global-sync" import { useGlobalSDK } from "./global-sdk" @@ -47,6 +47,43 @@ export type LocalProject = Partial & { worktree: string; expanded: bool export type ReviewDiffStyle = "unified" | "split" +export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) { + touch(key) + seed(key) + return key +} + +export function createSessionKeyReader(sessionKey: string | Accessor, ensure: (key: string) => void) { + const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey + return () => { + const value = key() + ensure(value) + return value + } +} + +export function pruneSessionKeys(input: { + keep?: string + max: number + used: Map + view: string[] + tabs: string[] +}) { + if (!input.keep) return [] + + const keys = new Set([...input.view, ...input.tabs]) + if (keys.size <= input.max) return [] + + const score = (key: string) => { + if (key === input.keep) return Number.MAX_SAFE_INTEGER + return input.used.get(key) ?? 0 + } + + return Array.from(keys) + .sort((a, b) => score(b) - score(a)) + .slice(input.max) +} + export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ name: "Layout", init: () => { @@ -172,20 +209,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( } function prune(keep?: string) { - if (!keep) return - - const keys = new Set() - for (const key of Object.keys(store.sessionView)) keys.add(key) - for (const key of Object.keys(store.sessionTabs)) keys.add(key) - if (keys.size <= MAX_SESSION_KEYS) return - - const score = (key: string) => { - if (key === keep) return Number.MAX_SAFE_INTEGER - return used.get(key) ?? 0 - } - - const ordered = Array.from(keys).sort((a, b) => score(b) - score(a)) - const drop = ordered.slice(MAX_SESSION_KEYS) + const drop = pruneSessionKeys({ + keep, + max: MAX_SESSION_KEYS, + used, + view: Object.keys(store.sessionView), + tabs: Object.keys(store.sessionTabs), + }) if (drop.length === 0) return setStore( @@ -233,6 +263,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, }) + const ensureKey = (key: string) => ensureSessionKey(key, touch, (sessionKey) => scroll.seed(sessionKey)) + createEffect(() => { if (!ready()) return if (meta.pruned) return @@ -616,22 +648,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, }, view(sessionKey: string | Accessor) { - const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey - - touch(key()) - scroll.seed(key()) - - createEffect( - on( - key, - (value) => { - touch(value) - scroll.seed(value) - }, - { defer: true }, - ), - ) - + const key = createSessionKeyReader(sessionKey, ensureKey) const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} }) const terminalOpened = createMemo(() => store.terminal?.opened ?? false) const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true) @@ -711,20 +728,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( } }, tabs(sessionKey: string | Accessor) { - const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey - - touch(key()) - - createEffect( - on( - key, - (value) => { - touch(value) - }, - { defer: true }, - ), - ) - + const key = createSessionKeyReader(sessionKey, ensureKey) const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] }) return { tabs, diff --git a/packages/app/src/context/notification-index.ts b/packages/app/src/context/notification-index.ts new file mode 100644 index 0000000000..0b316e7ec1 --- /dev/null +++ b/packages/app/src/context/notification-index.ts @@ -0,0 +1,66 @@ +type NotificationIndexItem = { + directory?: string + session?: string + viewed: boolean + type: string +} + +export function buildNotificationIndex(list: T[]) { + const sessionAll = new Map() + const sessionUnseen = new Map() + const sessionUnseenCount = new Map() + const sessionUnseenHasError = new Map() + const projectAll = new Map() + const projectUnseen = new Map() + const projectUnseenCount = new Map() + const projectUnseenHasError = new Map() + + for (const notification of list) { + const session = notification.session + if (session) { + const all = sessionAll.get(session) + if (all) all.push(notification) + else sessionAll.set(session, [notification]) + + if (!notification.viewed) { + const unseen = sessionUnseen.get(session) + if (unseen) unseen.push(notification) + else sessionUnseen.set(session, [notification]) + + sessionUnseenCount.set(session, (sessionUnseenCount.get(session) ?? 0) + 1) + if (notification.type === "error") sessionUnseenHasError.set(session, true) + } + } + + const directory = notification.directory + if (directory) { + const all = projectAll.get(directory) + if (all) all.push(notification) + else projectAll.set(directory, [notification]) + + if (!notification.viewed) { + const unseen = projectUnseen.get(directory) + if (unseen) unseen.push(notification) + else projectUnseen.set(directory, [notification]) + + projectUnseenCount.set(directory, (projectUnseenCount.get(directory) ?? 0) + 1) + if (notification.type === "error") projectUnseenHasError.set(directory, true) + } + } + } + + return { + session: { + all: sessionAll, + unseen: sessionUnseen, + unseenCount: sessionUnseenCount, + unseenHasError: sessionUnseenHasError, + }, + project: { + all: projectAll, + unseen: projectUnseen, + unseenCount: projectUnseenCount, + unseenHasError: projectUnseenHasError, + }, + } +} diff --git a/packages/app/src/context/notification.test.ts b/packages/app/src/context/notification.test.ts new file mode 100644 index 0000000000..44bacb7049 --- /dev/null +++ b/packages/app/src/context/notification.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test" +import { buildNotificationIndex } from "./notification-index" + +type Notification = { + type: "turn-complete" | "error" + session: string + directory: string + viewed: boolean + time: number +} + +const turn = (session: string, directory: string, viewed = false): Notification => ({ + type: "turn-complete", + session, + directory, + viewed, + time: 1, +}) + +const error = (session: string, directory: string, viewed = false): Notification => ({ + type: "error", + session, + directory, + viewed, + time: 1, +}) + +describe("buildNotificationIndex", () => { + test("builds unseen counts and unseen error flags", () => { + const list = [ + turn("s1", "d1", false), + error("s1", "d1", false), + turn("s1", "d1", true), + turn("s2", "d1", false), + error("s3", "d2", true), + ] + + const index = buildNotificationIndex(list) + + expect(index.session.all.get("s1")?.length).toBe(3) + expect(index.session.unseen.get("s1")?.length).toBe(2) + expect(index.session.unseenCount.get("s1")).toBe(2) + expect(index.session.unseenHasError.get("s1")).toBe(true) + + expect(index.session.unseenCount.get("s2")).toBe(1) + expect(index.session.unseenHasError.get("s2") ?? false).toBe(false) + expect(index.session.unseenCount.get("s3") ?? 0).toBe(0) + expect(index.session.unseenHasError.get("s3") ?? false).toBe(false) + + expect(index.project.unseenCount.get("d1")).toBe(3) + expect(index.project.unseenHasError.get("d1")).toBe(true) + expect(index.project.unseenCount.get("d2") ?? 0).toBe(0) + expect(index.project.unseenHasError.get("d2") ?? false).toBe(false) + }) + + test("updates selectors after viewed transitions", () => { + const list = [turn("s1", "d1", false), error("s1", "d1", false), turn("s2", "d1", false)] + const next = list.map((item) => (item.session === "s1" ? { ...item, viewed: true } : item)) + + const before = buildNotificationIndex(list) + const after = buildNotificationIndex(next) + + expect(before.session.unseenCount.get("s1")).toBe(2) + expect(before.session.unseenHasError.get("s1")).toBe(true) + expect(before.project.unseenCount.get("d1")).toBe(3) + expect(before.project.unseenHasError.get("d1")).toBe(true) + + expect(after.session.unseenCount.get("s1") ?? 0).toBe(0) + expect(after.session.unseenHasError.get("s1") ?? false).toBe(false) + expect(after.project.unseenCount.get("d1")).toBe(1) + expect(after.project.unseenHasError.get("d1") ?? false).toBe(false) + }) +}) diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 6c110cae14..b876bd8627 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -13,6 +13,7 @@ import { decode64 } from "@/utils/base64" import { EventSessionError } from "@opencode-ai/sdk/v2" import { Persist, persisted } from "@/utils/persist" import { playSound, soundSrc } from "@/utils/sound" +import { buildNotificationIndex } from "./notification-index" type NotificationBase = { directory?: string @@ -81,49 +82,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi setStore("list", (list) => pruneNotifications([...list, notification])) } - const index = createMemo(() => { - const sessionAll = new Map() - const sessionUnseen = new Map() - const projectAll = new Map() - const projectUnseen = new Map() - - for (const notification of store.list) { - const session = notification.session - if (session) { - const list = sessionAll.get(session) - if (list) list.push(notification) - else sessionAll.set(session, [notification]) - if (!notification.viewed) { - const unseen = sessionUnseen.get(session) - if (unseen) unseen.push(notification) - else sessionUnseen.set(session, [notification]) - } - } - - const directory = notification.directory - if (directory) { - const list = projectAll.get(directory) - if (list) list.push(notification) - else projectAll.set(directory, [notification]) - if (!notification.viewed) { - const unseen = projectUnseen.get(directory) - if (unseen) unseen.push(notification) - else projectUnseen.set(directory, [notification]) - } - } - } - - return { - session: { - all: sessionAll, - unseen: sessionUnseen, - }, - project: { - all: projectAll, - unseen: projectUnseen, - }, - } - }) + const index = createMemo(() => buildNotificationIndex(store.list)) const unsub = globalSDK.event.listen((e) => { const event = e.details @@ -208,6 +167,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi unseen(session: string) { return index().session.unseen.get(session) ?? empty }, + unseenCount(session: string) { + return index().session.unseenCount.get(session) ?? 0 + }, + unseenHasError(session: string) { + return index().session.unseenHasError.get(session) ?? false + }, markViewed(session: string) { setStore("list", (n) => n.session === session, "viewed", true) }, @@ -219,6 +184,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi unseen(directory: string) { return index().project.unseen.get(directory) ?? empty }, + unseenCount(directory: string) { + return index().project.unseenCount.get(directory) ?? 0 + }, + unseenHasError(directory: string) { + return index().project.unseenHasError.get(directory) ?? false + }, markViewed(directory: string) { setStore("list", (n) => n.directory === directory, "viewed", true) }, diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index f5d20ff8e9..127b9260b3 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -62,6 +62,9 @@ export type Platform = { /** Webview zoom level (desktop only) */ webviewZoom?: Accessor + + /** Check if an editor app exists (desktop only) */ + checkAppExists?(appName: string): Promise } export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({ diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index c307f6e72a..72693e6ef6 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -1,9 +1,9 @@ -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { batch, createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { usePlatform } from "@/context/platform" import { Persist, persisted } from "@/utils/persist" +import { checkServerHealth } from "@/utils/server-health" type StoredProject = { worktree: string; expanded: boolean } @@ -94,18 +94,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const isReady = createMemo(() => ready() && !!state.active) - const check = (url: string) => { - const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000) - const sdk = createOpencodeClient({ - baseUrl: url, - fetch: platform.fetch, - signal, - }) - return sdk.global - .health() - .then((x) => x.data?.healthy === true) - .catch(() => false) - } + const fetcher = platform.fetch ?? globalThis.fetch + const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy) createEffect(() => { const url = state.active diff --git a/packages/app/src/context/sync-optimistic.test.ts b/packages/app/src/context/sync-optimistic.test.ts new file mode 100644 index 0000000000..7deeddd6ee --- /dev/null +++ b/packages/app/src/context/sync-optimistic.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test" +import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import { applyOptimisticAdd, applyOptimisticRemove } from "./sync" + +const userMessage = (id: string, sessionID: string): Message => ({ + id, + sessionID, + role: "user", + time: { created: 1 }, + agent: "assistant", + model: { providerID: "openai", modelID: "gpt" }, +}) + +const textPart = (id: string, sessionID: string, messageID: string): Part => ({ + id, + sessionID, + messageID, + type: "text", + text: id, +}) + +describe("sync optimistic reducers", () => { + test("applyOptimisticAdd inserts message in sorted order and stores parts", () => { + const sessionID = "ses_1" + const draft = { + message: { [sessionID]: [userMessage("msg_2", sessionID)] }, + part: {} as Record, + } + + applyOptimisticAdd(draft, { + sessionID, + message: userMessage("msg_1", sessionID), + parts: [textPart("prt_2", sessionID, "msg_1"), textPart("prt_1", sessionID, "msg_1")], + }) + + expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2"]) + expect(draft.part.msg_1?.map((x) => x.id)).toEqual(["prt_1", "prt_2"]) + }) + + test("applyOptimisticRemove removes message and part entries", () => { + const sessionID = "ses_1" + const draft = { + message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_2", sessionID)] }, + part: { + msg_1: [textPart("prt_1", sessionID, "msg_1")], + msg_2: [textPart("prt_2", sessionID, "msg_2")], + } as Record, + } + + applyOptimisticRemove(draft, { sessionID, messageID: "msg_1" }) + + expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_2"]) + expect(draft.part.msg_1).toBeUndefined() + expect(draft.part.msg_2).toHaveLength(1) + }) +}) diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 0c63652450..66c53dc802 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -11,6 +11,43 @@ const keyFor = (directory: string, id: string) => `${directory}\n${id}` const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) +type OptimisticStore = { + message: Record + part: Record +} + +type OptimisticAddInput = { + sessionID: string + message: Message + parts: Part[] +} + +type OptimisticRemoveInput = { + sessionID: string + messageID: string +} + +export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) { + const messages = draft.message[input.sessionID] + if (!messages) { + draft.message[input.sessionID] = [input.message] + } + if (messages) { + const result = Binary.search(messages, input.message.id, (m) => m.id) + messages.splice(result.index, 0, input.message) + } + draft.part[input.message.id] = input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)) +} + +export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) { + const messages = draft.message[input.sessionID] + if (messages) { + const result = Binary.search(messages, input.messageID, (m) => m.id) + if (result.found) messages.splice(result.index, 1) + } + delete draft.part[input.messageID] +} + export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", init: () => { @@ -21,6 +58,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ type Setter = Child[1] const current = createMemo(() => globalSync.child(sdk.directory)) + const target = (directory?: string) => { + if (!directory || directory === sdk.directory) return current() + return globalSync.child(directory) + } const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") const chunk = 400 const inflight = new Map>() @@ -107,6 +148,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }, session: { get: getSession, + optimistic: { + add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) { + const [, setStore] = target(input.directory) + setStore( + produce((draft) => { + applyOptimisticAdd(draft as OptimisticStore, input) + }), + ) + }, + remove(input: { directory?: string; sessionID: string; messageID: string }) { + const [, setStore] = target(input.directory) + setStore( + produce((draft) => { + applyOptimisticRemove(draft as OptimisticStore, input) + }), + ) + }, + }, addOptimisticMessage(input: { sessionID: string messageID: string @@ -122,16 +181,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ agent: input.agent, model: input.model, } - current()[1]( + const [, setStore] = target() + setStore( produce((draft) => { - const messages = draft.message[input.sessionID] - if (!messages) { - draft.message[input.sessionID] = [message] - } else { - const result = Binary.search(messages, input.messageID, (m) => m.id) - messages.splice(result.index, 0, message) - } - draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)) + applyOptimisticAdd(draft as OptimisticStore, { + sessionID: input.sessionID, + message, + parts: input.parts, + }) }), ) }, diff --git a/packages/app/src/context/terminal.test.ts b/packages/app/src/context/terminal.test.ts new file mode 100644 index 0000000000..d8c8cfcd4f --- /dev/null +++ b/packages/app/src/context/terminal.test.ts @@ -0,0 +1,38 @@ +import { beforeAll, describe, expect, mock, test } from "bun:test" + +let getWorkspaceTerminalCacheKey: (dir: string) => string +let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[] + +beforeAll(async () => { + mock.module("@solidjs/router", () => ({ + useParams: () => ({}), + })) + mock.module("@opencode-ai/ui/context", () => ({ + createSimpleContext: () => ({ + use: () => undefined, + provider: () => undefined, + }), + })) + const mod = await import("./terminal") + getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey + getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys +}) + +describe("getWorkspaceTerminalCacheKey", () => { + test("uses workspace-only directory cache key", () => { + expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__") + }) +}) + +describe("getLegacyTerminalStorageKeys", () => { + test("keeps workspace storage path when no legacy session id", () => { + expect(getLegacyTerminalStorageKeys("/repo")).toEqual(["/repo/terminal.v1"]) + }) + + test("includes legacy session path before workspace path", () => { + expect(getLegacyTerminalStorageKeys("/repo", "session-123")).toEqual([ + "/repo/terminal/session-123.v1", + "/repo/terminal.v1", + ]) + }) +}) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 0c383a78d2..76e8cf0f73 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -19,15 +19,24 @@ export type LocalPTY = { const WORKSPACE_KEY = "__workspace__" const MAX_TERMINAL_SESSIONS = 20 -type TerminalSession = ReturnType +export function getWorkspaceTerminalCacheKey(dir: string) { + return `${dir}:${WORKSPACE_KEY}` +} + +export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) { + if (!legacySessionID) return [`${dir}/terminal.v1`] + return [`${dir}/terminal/${legacySessionID}.v1`, `${dir}/terminal.v1`] +} + +type TerminalSession = ReturnType type TerminalCacheEntry = { value: TerminalSession dispose: VoidFunction } -function createTerminalSession(sdk: ReturnType, dir: string, session?: string) { - const legacy = session ? [`${dir}/terminal/${session}.v1`, `${dir}/terminal.v1`] : [`${dir}/terminal.v1`] +function createWorkspaceTerminalSession(sdk: ReturnType, dir: string, legacySessionID?: string) { + const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID) const numberFromTitle = (title: string) => { const match = title.match(/^Terminal (\d+)$/) @@ -235,8 +244,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont } } - const load = (dir: string, session?: string) => { - const key = `${dir}:${WORKSPACE_KEY}` + const loadWorkspace = (dir: string, legacySessionID?: string) => { + // Terminals are workspace-scoped so tabs persist while switching sessions in the same directory. + const key = getWorkspaceTerminalCacheKey(dir) const existing = cache.get(key) if (existing) { cache.delete(key) @@ -245,7 +255,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont } const entry = createRoot((dispose) => ({ - value: createTerminalSession(sdk, dir, session), + value: createWorkspaceTerminalSession(sdk, dir, legacySessionID), dispose, })) @@ -254,7 +264,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont return entry.value } - const workspace = createMemo(() => load(params.dir!, params.id)) + const workspace = createMemo(() => loadWorkspace(params.dir!, params.id)) return { ready: () => workspace().ready(), diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 35f805dbc6..77a3edb062 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -28,8 +28,8 @@ export const dict = { "command.settings.open": "فتح الإعدادات", "command.session.previous": "الجلسة السابقة", "command.session.next": "الجلسة التالية", - "command.session.previous.unseen": "Previous unread session", - "command.session.next.unseen": "Next unread session", + "command.session.previous.unseen": "الجلسة غير المقروءة السابقة", + "command.session.next.unseen": "الجلسة غير المقروءة التالية", "command.session.archive": "أرشفة الجلسة", "command.palette": "لوحة الأوامر", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index dc8969f7b9..a743a3d896 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -28,8 +28,8 @@ export const dict = { "command.settings.open": "Abrir configurações", "command.session.previous": "Sessão anterior", "command.session.next": "Próxima sessão", - "command.session.previous.unseen": "Previous unread session", - "command.session.next.unseen": "Next unread session", + "command.session.previous.unseen": "Sessão não lida anterior", + "command.session.next.unseen": "Próxima sessão não lida", "command.session.archive": "Arquivar sessão", "command.palette": "Paleta de comandos", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 106ddcf6ff..88704607b3 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -28,8 +28,8 @@ export const dict = { "command.settings.open": "Åbn indstillinger", "command.session.previous": "Forrige session", "command.session.next": "Næste session", - "command.session.previous.unseen": "Previous unread session", - "command.session.next.unseen": "Next unread session", + "command.session.previous.unseen": "Forrige ulæste session", + "command.session.next.unseen": "Næste ulæste session", "command.session.archive": "Arkivér session", "command.palette": "Kommandopalette", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index a240e54750..a4d12d4454 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -32,8 +32,8 @@ export const dict = { "command.settings.open": "Einstellungen öffnen", "command.session.previous": "Vorherige Sitzung", "command.session.next": "Nächste Sitzung", - "command.session.previous.unseen": "Previous unread session", - "command.session.next.unseen": "Next unread session", + "command.session.previous.unseen": "Vorherige ungelesene Sitzung", + "command.session.next.unseen": "Nächste ungelesene Sitzung", "command.session.archive": "Sitzung archivieren", "command.palette": "Befehlspalette", @@ -147,6 +147,44 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} verbunden", "provider.connect.toast.connected.description": "{{provider}} Modelle sind jetzt verfügbar.", + "provider.custom.title": "Benutzerdefinierter Anbieter", + "provider.custom.description.prefix": "Konfigurieren Sie einen OpenAI-kompatiblen Anbieter. Siehe die ", + "provider.custom.description.link": "Anbieter-Konfigurationsdokumente", + "provider.custom.description.suffix": ".", + "provider.custom.field.providerID.label": "Anbieter-ID", + "provider.custom.field.providerID.placeholder": "myprovider", + "provider.custom.field.providerID.description": "Kleinbuchstaben, Zahlen, Bindestriche oder Unterstriche", + "provider.custom.field.name.label": "Anzeigename", + "provider.custom.field.name.placeholder": "Mein KI-Anbieter", + "provider.custom.field.baseURL.label": "Basis-URL", + "provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1", + "provider.custom.field.apiKey.label": "API-Schlüssel", + "provider.custom.field.apiKey.placeholder": "API-Schlüssel", + "provider.custom.field.apiKey.description": + "Optional. Leer lassen, wenn Sie die Authentifizierung über Header verwalten.", + "provider.custom.models.label": "Modelle", + "provider.custom.models.id.label": "ID", + "provider.custom.models.id.placeholder": "model-id", + "provider.custom.models.name.label": "Name", + "provider.custom.models.name.placeholder": "Anzeigename", + "provider.custom.models.remove": "Modell entfernen", + "provider.custom.models.add": "Modell hinzufügen", + "provider.custom.headers.label": "Header (optional)", + "provider.custom.headers.key.label": "Header", + "provider.custom.headers.key.placeholder": "Header-Name", + "provider.custom.headers.value.label": "Wert", + "provider.custom.headers.value.placeholder": "wert", + "provider.custom.headers.remove": "Header entfernen", + "provider.custom.headers.add": "Header hinzufügen", + "provider.custom.error.providerID.required": "Anbieter-ID ist erforderlich", + "provider.custom.error.providerID.format": "Verwenden Sie Kleinbuchstaben, Zahlen, Bindestriche oder Unterstriche", + "provider.custom.error.providerID.exists": "Diese Anbieter-ID existiert bereits", + "provider.custom.error.name.required": "Anzeigename ist erforderlich", + "provider.custom.error.baseURL.required": "Basis-URL ist erforderlich", + "provider.custom.error.baseURL.format": "Muss mit http:// oder https:// beginnen", + "provider.custom.error.required": "Erforderlich", + "provider.custom.error.duplicate": "Duplikat", + "provider.disconnect.toast.disconnected.title": "{{provider}} getrennt", "provider.disconnect.toast.disconnected.description": "Die {{provider}}-Modelle sind nicht mehr verfügbar.", "model.tag.free": "Kostenlos", @@ -380,6 +418,7 @@ export const dict = { "Wurzelelement nicht gefunden. Haben Sie vergessen, es in Ihre index.html aufzunehmen? Oder wurde das id-Attribut falsch geschrieben?", "error.globalSync.connectFailed": "Verbindung zum Server fehlgeschlagen. Läuft ein Server unter `{{url}}`?", + "directory.error.invalidUrl": "Ungültiges Verzeichnis in der URL.", "error.chain.unknown": "Unbekannter Fehler", "error.chain.causedBy": "Verursacht durch:", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 32c4695db2..4d7d571afb 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -149,6 +149,43 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} connected", "provider.connect.toast.connected.description": "{{provider}} models are now available to use.", + "provider.custom.title": "Custom provider", + "provider.custom.description.prefix": "Configure an OpenAI-compatible provider. See the ", + "provider.custom.description.link": "provider config docs", + "provider.custom.description.suffix": ".", + "provider.custom.field.providerID.label": "Provider ID", + "provider.custom.field.providerID.placeholder": "myprovider", + "provider.custom.field.providerID.description": "Lowercase letters, numbers, hyphens, or underscores", + "provider.custom.field.name.label": "Display name", + "provider.custom.field.name.placeholder": "My AI Provider", + "provider.custom.field.baseURL.label": "Base URL", + "provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1", + "provider.custom.field.apiKey.label": "API key", + "provider.custom.field.apiKey.placeholder": "API key", + "provider.custom.field.apiKey.description": "Optional. Leave empty if you manage auth via headers.", + "provider.custom.models.label": "Models", + "provider.custom.models.id.label": "ID", + "provider.custom.models.id.placeholder": "model-id", + "provider.custom.models.name.label": "Name", + "provider.custom.models.name.placeholder": "Display Name", + "provider.custom.models.remove": "Remove model", + "provider.custom.models.add": "Add model", + "provider.custom.headers.label": "Headers (optional)", + "provider.custom.headers.key.label": "Header", + "provider.custom.headers.key.placeholder": "Header-Name", + "provider.custom.headers.value.label": "Value", + "provider.custom.headers.value.placeholder": "value", + "provider.custom.headers.remove": "Remove header", + "provider.custom.headers.add": "Add header", + "provider.custom.error.providerID.required": "Provider ID is required", + "provider.custom.error.providerID.format": "Use lowercase letters, numbers, hyphens, or underscores", + "provider.custom.error.providerID.exists": "That provider ID already exists", + "provider.custom.error.name.required": "Display name is required", + "provider.custom.error.baseURL.required": "Base URL is required", + "provider.custom.error.baseURL.format": "Must start with http:// or https://", + "provider.custom.error.required": "Required", + "provider.custom.error.duplicate": "Duplicate", + "provider.disconnect.toast.disconnected.title": "{{provider}} disconnected", "provider.disconnect.toast.disconnected.description": "{{provider}} models are no longer available.", @@ -404,6 +441,7 @@ export const dict = { "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?", "error.globalSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?", + "directory.error.invalidUrl": "Invalid directory in URL.", "error.chain.unknown": "Unknown error", "error.chain.causedBy": "Caused by:", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index c94f407c6c..5d48ba4949 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -28,8 +28,8 @@ export const dict = { "command.settings.open": "Abrir ajustes", "command.session.previous": "Sesión anterior", "command.session.next": "Siguiente sesión", - "command.session.previous.unseen": "Previous unread session", - "command.session.next.unseen": "Next unread session", + "command.session.previous.unseen": "Sesión no leída anterior", + "command.session.next.unseen": "Siguiente sesión no leída", "command.session.archive": "Archivar sesión", "command.palette": "Paleta de comandos", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index f36d228045..a76e57ff15 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -28,8 +28,8 @@ export const dict = { "command.settings.open": "Ouvrir les paramètres", "command.session.previous": "Session précédente", "command.session.next": "Session suivante", - "command.session.previous.unseen": "Previous unread session", - "command.session.next.unseen": "Next unread session", + "command.session.previous.unseen": "Session non lue précédente", + "command.session.next.unseen": "Session non lue suivante", "command.session.archive": "Archiver la session", "command.palette": "Palette de commandes", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index c4ce4c40d4..e41dea9dc7 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -28,8 +28,8 @@ export const dict = { "command.settings.open": "設定を開く", "command.session.previous": "前のセッション", "command.session.next": "次のセッション", - "command.session.previous.unseen": "Previous unread session", - "command.session.next.unseen": "Next unread session", + "command.session.previous.unseen": "前の未読セッション", + "command.session.next.unseen": "次の未読セッション", "command.session.archive": "セッションをアーカイブ", "command.palette": "コマンドパレット", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 2a3f4ef815..a4f42a583e 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -32,8 +32,8 @@ export const dict = { "command.settings.open": "설정 열기", "command.session.previous": "이전 세션", "command.session.next": "다음 세션", - "command.session.previous.unseen": "Previous unread session", - "command.session.next.unseen": "Next unread session", + "command.session.previous.unseen": "이전 읽지 않은 세션", + "command.session.next.unseen": "다음 읽지 않은 세션", "command.session.archive": "세션 보관", "command.palette": "명령 팔레트", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 315b21f2cc..3de7837f80 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -31,8 +31,8 @@ export const dict = { "command.settings.open": "Åpne innstillinger", "command.session.previous": "Forrige sesjon", "command.session.next": "Neste sesjon", - "command.session.previous.unseen": "Previous unread session", - "command.session.next.unseen": "Next unread session", + "command.session.previous.unseen": "Forrige uleste økt", + "command.session.next.unseen": "Neste uleste økt", "command.session.archive": "Arkiver sesjon", "command.palette": "Kommandopalett", diff --git a/packages/app/src/i18n/parity.test.ts b/packages/app/src/i18n/parity.test.ts new file mode 100644 index 0000000000..a75dbd3a30 --- /dev/null +++ b/packages/app/src/i18n/parity.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test" +import { dict as en } from "./en" +import { dict as ar } from "./ar" +import { dict as br } from "./br" +import { dict as bs } from "./bs" +import { dict as da } from "./da" +import { dict as de } from "./de" +import { dict as es } from "./es" +import { dict as fr } from "./fr" +import { dict as ja } from "./ja" +import { dict as ko } from "./ko" +import { dict as no } from "./no" +import { dict as pl } from "./pl" +import { dict as ru } from "./ru" +import { dict as th } from "./th" +import { dict as zh } from "./zh" +import { dict as zht } from "./zht" + +const locales = [ar, br, bs, da, de, es, fr, ja, ko, no, pl, ru, th, zh, zht] +const keys = ["command.session.previous.unseen", "command.session.next.unseen"] as const + +describe("i18n parity", () => { + test("non-English locales translate targeted unseen session keys", () => { + for (const locale of locales) { + for (const key of keys) { + expect(locale[key]).toBeDefined() + expect(locale[key]).not.toBe(en[key]) + } + } + }) +}) diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 46a727448a..44bc4677be 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -28,8 +28,8 @@ export const dict = { "command.settings.open": "Otwórz ustawienia", "command.session.previous": "Poprzednia sesja", "command.session.next": "Następna sesja", - "command.session.previous.unseen": "Previous unread session", - "command.session.next.unseen": "Next unread session", + "command.session.previous.unseen": "Poprzednia nieprzeczytana sesja", + "command.session.next.unseen": "Następna nieprzeczytana sesja", "command.session.archive": "Zarchiwizuj sesję", "command.palette": "Paleta poleceń", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index e4f8b1eaae..28785c0e9f 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -28,8 +28,8 @@ export const dict = { "command.settings.open": "Открыть настройки", "command.session.previous": "Предыдущая сессия", "command.session.next": "Следующая сессия", - "command.session.previous.unseen": "Previous unread session", - "command.session.next.unseen": "Next unread session", + "command.session.previous.unseen": "Предыдущая непрочитанная сессия", + "command.session.next.unseen": "Следующая непрочитанная сессия", "command.session.archive": "Архивировать сессию", "command.palette": "Палитра команд", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index c81b1dff3c..9858f39d77 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -28,8 +28,8 @@ export const dict = { "command.settings.open": "เปิดการตั้งค่า", "command.session.previous": "เซสชันก่อนหน้า", "command.session.next": "เซสชันถัดไป", - "command.session.previous.unseen": "Previous unread session", - "command.session.next.unseen": "Next unread session", + "command.session.previous.unseen": "เซสชันที่ยังไม่ได้อ่านก่อนหน้า", + "command.session.next.unseen": "เซสชันที่ยังไม่ได้อ่านถัดไป", "command.session.archive": "จัดเก็บเซสชัน", "command.palette": "คำสั่งค้นหา", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index c3b87525cf..a8fda6f3a6 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -32,8 +32,8 @@ export const dict = { "command.settings.open": "打开设置", "command.session.previous": "上一个会话", "command.session.next": "下一个会话", - "command.session.previous.unseen": "Previous unread session", - "command.session.next.unseen": "Next unread session", + "command.session.previous.unseen": "上一个未读会话", + "command.session.next.unseen": "下一个未读会话", "command.session.archive": "归档会话", "command.palette": "命令面板", @@ -147,6 +147,43 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} 已连接", "provider.connect.toast.connected.description": "现在可以使用 {{provider}} 模型了。", + "provider.custom.title": "自定义提供商", + "provider.custom.description.prefix": "配置与 OpenAI 兼容的提供商。请查看", + "provider.custom.description.link": "提供商配置文档", + "provider.custom.description.suffix": "。", + "provider.custom.field.providerID.label": "提供商 ID", + "provider.custom.field.providerID.placeholder": "myprovider", + "provider.custom.field.providerID.description": "使用小写字母、数字、连字符或下划线", + "provider.custom.field.name.label": "显示名称", + "provider.custom.field.name.placeholder": "我的 AI 提供商", + "provider.custom.field.baseURL.label": "基础 URL", + "provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1", + "provider.custom.field.apiKey.label": "API 密钥", + "provider.custom.field.apiKey.placeholder": "API 密钥", + "provider.custom.field.apiKey.description": "可选。如果你通过请求头管理认证,可留空。", + "provider.custom.models.label": "模型", + "provider.custom.models.id.label": "ID", + "provider.custom.models.id.placeholder": "model-id", + "provider.custom.models.name.label": "名称", + "provider.custom.models.name.placeholder": "显示名称", + "provider.custom.models.remove": "移除模型", + "provider.custom.models.add": "添加模型", + "provider.custom.headers.label": "请求头(可选)", + "provider.custom.headers.key.label": "请求头", + "provider.custom.headers.key.placeholder": "Header-Name", + "provider.custom.headers.value.label": "值", + "provider.custom.headers.value.placeholder": "value", + "provider.custom.headers.remove": "移除请求头", + "provider.custom.headers.add": "添加请求头", + "provider.custom.error.providerID.required": "提供商 ID 为必填项", + "provider.custom.error.providerID.format": "请使用小写字母、数字、连字符或下划线", + "provider.custom.error.providerID.exists": "该提供商 ID 已存在", + "provider.custom.error.name.required": "显示名称为必填项", + "provider.custom.error.baseURL.required": "基础 URL 为必填项", + "provider.custom.error.baseURL.format": "必须以 http:// 或 https:// 开头", + "provider.custom.error.required": "必填", + "provider.custom.error.duplicate": "重复", + "provider.disconnect.toast.disconnected.title": "{{provider}} 已断开连接", "provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。", "model.tag.free": "免费", @@ -380,6 +417,7 @@ export const dict = { "error.dev.rootNotFound": "未找到根元素。你是不是忘了把它添加到 index.html?或者 id 属性拼写错了?", "error.globalSync.connectFailed": "无法连接到服务器。是否有服务器正在 `{{url}}` 运行?", + "directory.error.invalidUrl": "URL 中的目录无效。", "error.chain.unknown": "未知错误", "error.chain.causedBy": "原因:", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 7be29f036e..319f5c51d1 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -32,8 +32,8 @@ export const dict = { "command.settings.open": "開啟設定", "command.session.previous": "上一個工作階段", "command.session.next": "下一個工作階段", - "command.session.previous.unseen": "Previous unread session", - "command.session.next.unseen": "Next unread session", + "command.session.previous.unseen": "上一個未讀會話", + "command.session.next.unseen": "下一個未讀會話", "command.session.archive": "封存工作階段", "command.palette": "命令面板", @@ -144,6 +144,43 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} 已連線", "provider.connect.toast.connected.description": "現在可以使用 {{provider}} 模型了。", + "provider.custom.title": "自訂提供商", + "provider.custom.description.prefix": "設定與 OpenAI 相容的提供商。請參閱", + "provider.custom.description.link": "提供商設定文件", + "provider.custom.description.suffix": "。", + "provider.custom.field.providerID.label": "提供商 ID", + "provider.custom.field.providerID.placeholder": "myprovider", + "provider.custom.field.providerID.description": "使用小寫字母、數字、連字號或底線", + "provider.custom.field.name.label": "顯示名稱", + "provider.custom.field.name.placeholder": "我的 AI 提供商", + "provider.custom.field.baseURL.label": "基礎 URL", + "provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1", + "provider.custom.field.apiKey.label": "API 金鑰", + "provider.custom.field.apiKey.placeholder": "API 金鑰", + "provider.custom.field.apiKey.description": "選填。若您透過標頭管理驗證,可留空。", + "provider.custom.models.label": "模型", + "provider.custom.models.id.label": "ID", + "provider.custom.models.id.placeholder": "model-id", + "provider.custom.models.name.label": "名稱", + "provider.custom.models.name.placeholder": "顯示名稱", + "provider.custom.models.remove": "移除模型", + "provider.custom.models.add": "新增模型", + "provider.custom.headers.label": "標頭(選填)", + "provider.custom.headers.key.label": "標頭", + "provider.custom.headers.key.placeholder": "Header-Name", + "provider.custom.headers.value.label": "值", + "provider.custom.headers.value.placeholder": "value", + "provider.custom.headers.remove": "移除標頭", + "provider.custom.headers.add": "新增標頭", + "provider.custom.error.providerID.required": "提供商 ID 為必填", + "provider.custom.error.providerID.format": "請使用小寫字母、數字、連字號或底線", + "provider.custom.error.providerID.exists": "該提供商 ID 已存在", + "provider.custom.error.name.required": "顯示名稱為必填", + "provider.custom.error.baseURL.required": "基礎 URL 為必填", + "provider.custom.error.baseURL.format": "必須以 http:// 或 https:// 開頭", + "provider.custom.error.required": "必填", + "provider.custom.error.duplicate": "重複", + "provider.disconnect.toast.disconnected.title": "{{provider}} 已中斷連線", "provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。", "model.tag.free": "免費", @@ -377,6 +414,7 @@ export const dict = { "error.dev.rootNotFound": "找不到根元素。你是不是忘了把它新增到 index.html? 或者 id 屬性拼錯了?", "error.globalSync.connectFailed": "無法連線到伺服器。是否有伺服器正在 `{{url}}` 執行?", + "directory.error.invalidUrl": "URL 中的目錄無效。", "error.chain.unknown": "未知錯誤", "error.chain.causedBy": "原因:", diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index df3181133e..fb66820092 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,2 +1,3 @@ export { PlatformProvider, type Platform } from "./context/platform" export { AppBaseProviders, AppInterface } from "./app" +export { useCommand } from "./context/command" diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index da4667a827..2f4db85649 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -25,7 +25,7 @@ export default function Layout(props: ParentProps) { showToast({ variant: "error", title: language.t("common.requestFailed"), - description: "Invalid directory in URL.", + description: language.t("directory.error.invalidUrl"), }) navigate("/") }) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 251984d776..59adef4694 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2,53 +2,34 @@ import { batch, createEffect, createMemo, - createSignal, For, - Match, on, onCleanup, onMount, ParentProps, Show, - Switch, untrack, - type Accessor, type JSX, } from "solid-js" import { A, useNavigate, useParams } from "@solidjs/router" -import { useLayout, getAvatarColors, LocalProject } from "@/context/layout" +import { useLayout, LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { Persist, persisted } from "@/utils/persist" import { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" -import { Avatar } from "@opencode-ai/ui/avatar" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" 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 { ContextMenu } from "@opencode-ai/ui/context-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, type Message, type TextPart } from "@opencode-ai/sdk/v2/client" +import { Session, type Message } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" import { createStore, produce, reconcile } from "solid-js/store" -import { - DragDropProvider, - DragDropSensors, - DragOverlay, - SortableProvider, - closestCenter, - createSortable, -} from "@thisbeyond/solid-dnd" +import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" import { useProviders } from "@/hooks/use-providers" import { showToast, Toast, toaster } from "@opencode-ai/ui/toast" @@ -60,7 +41,6 @@ import { retry } from "@opencode-ai/util/retry" import { playSound, soundSrc } from "@/utils/sound" import { createAim } from "@/utils/aim" import { Worktree as WorktreeState } from "@/utils/worktree" -import { agentColor } from "@/utils/agent" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" @@ -75,6 +55,26 @@ import { DialogEditProject } from "@/components/dialog-edit-project" import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" import { useLanguage, type Locale } from "@/context/language" +import { + childMapByParent, + displayName, + errorMessage, + getDraggableId, + sortedRootSessions, + syncWorkspaceOrder, + workspaceKey, +} from "./layout/helpers" +import { collectOpenProjectDeepLinks, deepLinkEvent, drainPendingDeepLinks } from "./layout/deep-links" +import { createInlineEditorController } from "./layout/inline-editor" +import { + LocalWorkspace, + SortableWorkspace, + WorkspaceDragOverlay, + type WorkspaceSidebarContext, +} from "./layout/sidebar-workspace" +import { workspaceOpenState } from "./layout/sidebar-workspace-helpers" +import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project" +import { SidebarContent } from "./layout/sidebar-shell" export default function Layout(props: ParentProps) { const [store, setStore, , ready] = persisted( @@ -119,6 +119,7 @@ export default function Layout(props: ParentProps) { dark: "theme.scheme.dark", } const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme]) + const currentDir = createMemo(() => decode64(params.dir) ?? "") const [state, setState] = createStore({ autoselect: !initialDirectory, @@ -129,10 +130,7 @@ export default function Layout(props: ParentProps) { nav: undefined as HTMLElement | undefined, }) - const [editor, setEditor] = createStore({ - active: "" as string, - value: "", - }) + const editor = createInlineEditorController() const setBusy = (directory: string, value: boolean) => { const key = workspaceKey(directory) setState("busyWorkspaces", (prev) => { @@ -143,8 +141,6 @@ export default function Layout(props: ParentProps) { }) } const isBusy = (directory: string) => state.busyWorkspaces.has(workspaceKey(directory)) - const editorRef = { current: undefined as HTMLInputElement | undefined } - const navLeave = { current: undefined as number | undefined } const aim = createAim({ @@ -165,6 +161,8 @@ export default function Layout(props: ParentProps) { const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined) const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering()) + const clearHoverProjectSoon = () => queueMicrotask(() => setState("hoverProject", undefined)) + const setHoverSession = (id: string | undefined) => setState("hoverSession", id) const hoverProjectData = createMemo(() => { const id = state.hoverProject @@ -216,100 +214,11 @@ export default function Layout(props: ParentProps) { setState("autoselect", false) }) - const editorOpen = (id: string) => editor.active === id - const editorValue = () => editor.value - - const openEditor = (id: string, value: string) => { - if (!id) return - setEditor({ active: id, value }) - } - - const closeEditor = () => setEditor({ active: "", value: "" }) - - const saveEditor = (callback: (next: string) => void) => { - const next = editor.value.trim() - if (!next) { - closeEditor() - return - } - closeEditor() - callback(next) - } - - const editorKeyDown = (event: KeyboardEvent, callback: (next: string) => void) => { - if (event.key === "Enter") { - event.preventDefault() - saveEditor(callback) - return - } - if (event.key === "Escape") { - event.preventDefault() - closeEditor() - } - } - - const InlineEditor = (props: { - id: string - value: Accessor - onSave: (next: string) => void - class?: string - displayClass?: string - editing?: boolean - stopPropagation?: boolean - openOnDblClick?: boolean - }) => { - const isEditing = () => props.editing ?? editorOpen(props.id) - const stopEvents = () => props.stopPropagation ?? false - const allowDblClick = () => props.openOnDblClick ?? true - const stopPropagation = (event: Event) => { - if (!stopEvents()) return - event.stopPropagation() - } - const handleDblClick = (event: MouseEvent) => { - if (!allowDblClick()) return - stopPropagation(event) - openEditor(props.id, props.value()) - } - - return ( - - {props.value()} - - } - > - { - editorRef.current = el - requestAnimationFrame(() => el.focus()) - }} - value={editorValue()} - class={props.class} - onInput={(event) => setEditor("value", event.currentTarget.value)} - onKeyDown={(event) => { - event.stopPropagation() - editorKeyDown(event, props.onSave) - }} - onBlur={() => closeEditor()} - onPointerDown={stopPropagation} - onClick={stopPropagation} - onDblClick={stopPropagation} - onMouseDown={stopPropagation} - onMouseUp={stopPropagation} - onTouchStart={stopPropagation} - /> - - ) - } + const editorOpen = editor.editorOpen + const openEditor = editor.openEditor + const closeEditor = editor.closeEditor + const setEditor = editor.setEditor + const InlineEditor = editor.InlineEditor function cycleTheme(direction = 1) { const ids = availableThemeEntries().map(([id]) => id) @@ -466,10 +375,9 @@ export default function Layout(props: ParentProps) { } } - const currentDir = decode64(params.dir) const currentSession = params.id - if (directory === currentDir && props.sessionID === currentSession) return - if (directory === currentDir && session?.parentID === currentSession) return + if (directory === currentDir() && props.sessionID === currentSession) return + if (directory === currentDir() && session?.parentID === currentSession) return const existingToastId = toastBySession.get(sessionKey) if (existingToastId !== undefined) toaster.dismiss(existingToastId) @@ -495,20 +403,19 @@ export default function Layout(props: ParentProps) { onCleanup(unsub) createEffect(() => { - const currentDir = decode64(params.dir) const currentSession = params.id - if (!currentDir || !currentSession) return - const sessionKey = `${currentDir}:${currentSession}` + if (!currentDir() || !currentSession) return + const sessionKey = `${currentDir()}:${currentSession}` const toastId = toastBySession.get(sessionKey) if (toastId !== undefined) { toaster.dismiss(toastId) toastBySession.delete(sessionKey) alertedAtBySession.delete(sessionKey) } - const [store] = globalSync.child(currentDir, { bootstrap: false }) + const [store] = globalSync.child(currentDir(), { bootstrap: false }) const childSessions = store.session.filter((s) => s.parentID === currentSession) for (const child of childSessions) { - const childKey = `${currentDir}:${child.id}` + const childKey = `${currentDir()}:${child.id}` const childToastId = toastBySession.get(childKey) if (childToastId !== undefined) { toaster.dismiss(childToastId) @@ -519,20 +426,6 @@ export default function Layout(props: ParentProps) { }) }) - function sortSessions(now: number) { - const oneMinuteAgo = now - 60 * 1000 - return (a: Session, b: Session) => { - const aUpdated = a.time.updated ?? a.time.created - const bUpdated = b.time.updated ?? b.time.created - const aRecent = aUpdated > oneMinuteAgo - const bRecent = bUpdated > oneMinuteAgo - if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 - if (aRecent && !bRecent) return -1 - if (!aRecent && bRecent) return 1 - return bUpdated - aUpdated - } - } - function scrollToSession(sessionId: string, sessionKey: string) { if (!scrollContainerRef) return if (state.scrollSessionKey === sessionKey) return @@ -549,7 +442,7 @@ export default function Layout(props: ParentProps) { } const currentProject = createMemo(() => { - const directory = decode64(params.dir) + const directory = currentDir() if (!directory) return const projects = layout.projects.list() @@ -614,8 +507,6 @@ export default function Layout(props: ParentProps) { ), ) - const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "") - const workspaceName = (directory: string, projectId?: string, branch?: string) => { const key = workspaceKey(directory) const direct = store.workspaceName[key] ?? store.workspaceName[directory] @@ -652,15 +543,12 @@ export default function Layout(props: ParentProps) { const local = project.worktree const dirs = [project.worktree, ...(project.sandboxes ?? [])] const existing = store.workspaceOrder[project.worktree] + const merged = syncWorkspaceOrder(local, dirs, existing) if (!existing) { - setStore("workspaceOrder", project.worktree, dirs) + setStore("workspaceOrder", project.worktree, merged) return } - const keep = existing.filter((d) => d !== local && dirs.includes(d)) - const missing = dirs.filter((d) => d !== local && !existing.includes(d)) - const merged = [local, ...missing, ...keep] - if (merged.length !== existing.length) { setStore("workspaceOrder", project.worktree, merged) return @@ -687,29 +575,23 @@ export default function Layout(props: ParentProps) { const currentSessions = createMemo(() => { const project = currentProject() if (!project) return [] as Session[] - const compare = sortSessions(Date.now()) + const now = Date.now() if (workspaceSetting()) { const dirs = workspaceIds(project) - const activeDir = decode64(params.dir) ?? "" + const activeDir = currentDir() const result: Session[] = [] for (const dir of dirs) { const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree const active = dir === activeDir if (!expanded && !active) continue const [dirStore] = globalSync.child(dir, { bootstrap: true }) - const dirSessions = dirStore.session - .filter((session) => session.directory === dirStore.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(compare) + const dirSessions = sortedRootSessions(dirStore, now) result.push(...dirSessions) } return result } const [projectStore] = globalSync.child(project.worktree) - return projectStore.session - .filter((session) => session.directory === projectStore.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(compare) + return sortedRootSessions(projectStore, now) }) type PrefetchQueue = { @@ -951,7 +833,7 @@ export default function Layout(props: ParentProps) { const sessions = currentSessions() if (sessions.length === 0) return - const hasUnseen = sessions.some((session) => notification.session.unseen(session.id).length > 0) + const hasUnseen = sessions.some((session) => notification.session.unseenCount(session.id) > 0) if (!hasUnseen) return const activeIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1 @@ -961,7 +843,7 @@ export default function Layout(props: ParentProps) { const index = offset > 0 ? (start + i) % sessions.length : (start - i + sessions.length) % sessions.length const session = sessions[index] if (!session) continue - if (notification.session.unseen(session.id).length === 0) continue + if (notification.session.unseenCount(session.id) === 0) continue prefetchSession(session, "high") @@ -1019,7 +901,7 @@ export default function Layout(props: ParentProps) { } } - command.register(() => { + command.register("layout", () => { const commands: CommandOption[] = [ { id: "sidebar.toggle", @@ -1093,6 +975,18 @@ export default function Layout(props: ParentProps) { if (session) archiveSession(session) }, }, + { + id: "workspace.new", + title: language.t("workspace.new"), + category: language.t("command.category.workspace"), + keybind: "mod+shift+w", + disabled: !workspaceSetting(), + onSelect: () => { + const project = currentProject() + if (!project) return + return createWorkspace(project) + }, + }, { id: "workspace.toggle", title: language.t("command.workspace.toggle"), @@ -1217,33 +1111,13 @@ export default function Layout(props: ParentProps) { if (navigate) navigateToProject(directory) } - const deepLinkEvent = "opencode:deep-link" - - const parseDeepLink = (input: string) => { - if (!input.startsWith("opencode://")) return - const url = new URL(input) - if (url.hostname !== "open-project") return - const directory = url.searchParams.get("directory") - if (!directory) return - return directory - } - const handleDeepLinks = (urls: string[]) => { if (!server.isLocal()) return - for (const input of urls) { - const directory = parseDeepLink(input) - if (!directory) continue + for (const directory of collectOpenProjectDeepLinks(urls)) { openProject(directory) } } - const drainDeepLinks = () => { - const pending = window.__OPENCODE__?.deepLinks ?? [] - if (pending.length === 0) return - if (window.__OPENCODE__) window.__OPENCODE__.deepLinks = [] - handleDeepLinks(pending) - } - onMount(() => { const handler = (event: Event) => { const detail = (event as CustomEvent<{ urls: string[] }>).detail @@ -1252,13 +1126,11 @@ export default function Layout(props: ParentProps) { handleDeepLinks(urls) } - drainDeepLinks() + handleDeepLinks(drainPendingDeepLinks(window)) window.addEventListener(deepLinkEvent, handler as EventListener) onCleanup(() => window.removeEventListener(deepLinkEvent, handler as EventListener)) }) - const displayName = (project: LocalProject) => project.name || getFilename(project.worktree) - async function renameProject(project: LocalProject, next: string) { const current = displayName(project) if (next === current) return @@ -1286,6 +1158,18 @@ export default function Layout(props: ParentProps) { else navigate("/") } + function toggleProjectWorkspaces(project: LocalProject) { + const enabled = layout.sidebar.workspaces(project.worktree)() + if (enabled) { + layout.sidebar.toggleWorkspaces(project.worktree) + return + } + if (project.vcs !== "git") return + layout.sidebar.toggleWorkspaces(project.worktree) + } + + const showEditProjectDialog = (project: LocalProject) => dialog.show(() => ) + async function chooseProject() { function resolve(result: string | string[] | null) { if (Array.isArray(result)) { @@ -1312,15 +1196,6 @@ export default function Layout(props: ParentProps) { } } - const errorMessage = (err: unknown) => { - if (err && typeof err === "object" && "data" in err) { - const data = (err as { data?: { message?: string } }).data - if (data?.message) return data.message - } - if (err instanceof Error) return err.message - return language.t("common.requestFailed") - } - const deleteWorkspace = async (root: string, directory: string) => { if (directory === root) return @@ -1332,7 +1207,7 @@ export default function Layout(props: ParentProps) { .catch((err) => { showToast({ title: language.t("workspace.delete.failed.title"), - description: errorMessage(err), + description: errorMessage(err, language.t("common.requestFailed")), }) return false }) @@ -1344,7 +1219,7 @@ export default function Layout(props: ParentProps) { layout.projects.close(directory) layout.projects.open(root) - if (params.dir && decode64(params.dir) === directory) { + if (params.dir && currentDir() === directory) { navigateToProject(root) } } @@ -1371,7 +1246,7 @@ export default function Layout(props: ParentProps) { .catch((err) => { showToast({ title: language.t("workspace.reset.failed.title"), - description: errorMessage(err), + description: errorMessage(err, language.t("common.requestFailed")), }) return false }) @@ -1584,7 +1459,7 @@ export default function Layout(props: ParentProps) { if (!project) return if (workspaceSetting()) { - const activeDir = decode64(params.dir) ?? "" + const activeDir = currentDir() const dirs = [project.worktree, ...(project.sandboxes ?? [])] for (const directory of dirs) { const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree @@ -1598,14 +1473,6 @@ export default function Layout(props: ParentProps) { globalSync.project.loadSessions(project.worktree) }) - function getDraggableId(event: unknown): string | undefined { - if (typeof event !== "object" || event === null) return undefined - if (!("draggable" in event)) return undefined - const draggable = (event as { draggable?: { id?: unknown } }).draggable - if (!draggable) return undefined - return typeof draggable.id === "string" ? draggable.id : undefined - } - function handleDragStart(event: unknown) { const id = getDraggableId(event) if (!id) return @@ -1634,16 +1501,15 @@ export default function Layout(props: ParentProps) { const local = project.worktree const dirs = [local, ...(project.sandboxes ?? [])] const active = currentProject() - const directory = active?.worktree === project.worktree ? decode64(params.dir) : undefined + const directory = active?.worktree === project.worktree ? currentDir() : undefined const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false const existing = store.workspaceOrder[project.worktree] if (!existing) return extra ? [...dirs, extra] : dirs - const keep = existing.filter((d) => d !== local && dirs.includes(d)) - const missing = dirs.filter((d) => d !== local && !existing.includes(d)) - const merged = [local, ...(pending && extra ? [extra] : []), ...missing, ...keep] + const merged = syncWorkspaceOrder(local, dirs, existing) + if (pending && extra) return [local, extra, ...merged.filter((directory) => directory !== local)] if (!extra) return merged if (pending) return merged return [...merged, extra] @@ -1686,877 +1552,6 @@ export default function Layout(props: ParentProps) { setStore("activeWorkspace", undefined) } - const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { - const notification = useNotification() - const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) - const hasError = createMemo(() => notifications().some((n) => n.type === "error")) - const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) - const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" - - return ( -
-
- 0 && props.notify }} - /> -
- 0 && props.notify}> -
- -
- ) - } - - const SessionItem = (props: { - session: Session - slug: string - mobile?: boolean - dense?: boolean - popover?: boolean - children?: Map - }): JSX.Element => { - const notification = useNotification() - const notifications = createMemo(() => notification.session.unseen(props.session.id)) - const hasError = createMemo(() => notifications().some((n) => n.type === "error")) - const [sessionStore] = globalSync.child(props.session.directory) - const hasPermissions = createMemo(() => { - const permissions = sessionStore.permission?.[props.session.id] ?? [] - if (permissions.length > 0) return true - - const childIDs = props.children?.get(props.session.id) - if (childIDs) { - for (const id of childIDs) { - const childPermissions = sessionStore.permission?.[id] ?? [] - if (childPermissions.length > 0) return true - } - return false - } - - const childSessions = sessionStore.session.filter((s) => s.parentID === props.session.id) - for (const child of childSessions) { - const childPermissions = sessionStore.permission?.[child.id] ?? [] - if (childPermissions.length > 0) return true - } - return false - }) - const isWorking = createMemo(() => { - if (hasPermissions()) return false - const status = sessionStore.session_status[props.session.id] - return status?.type === "busy" || status?.type === "retry" - }) - - const tint = createMemo(() => { - const messages = sessionStore.message[props.session.id] - if (!messages) return undefined - const user = messages - .slice() - .reverse() - .find((m) => m.role === "user") - if (!user?.agent) return undefined - - const agent = sessionStore.agent.find((a) => a.name === user.agent) - return agentColor(user.agent, 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 && sidebarExpanded()) - const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) - const isActive = createMemo(() => props.session.id === params.id) - - const hoverPrefetch = { current: undefined as ReturnType | undefined } - const cancelHoverPrefetch = () => { - if (hoverPrefetch.current === undefined) return - clearTimeout(hoverPrefetch.current) - hoverPrefetch.current = undefined - } - const scheduleHoverPrefetch = () => { - if (hoverPrefetch.current !== undefined) return - hoverPrefetch.current = setTimeout(() => { - hoverPrefetch.current = undefined - prefetchSession(props.session) - }, 200) - } - - onCleanup(cancelHoverPrefetch) - - 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")} - onClick={() => { - setState("hoverSession", undefined) - if (layout.sidebar.opened()) return - queueMicrotask(() => setState("hoverProject", undefined)) - }} - > -
-
- }> - - - - -
- - -
- - 0}> -
- - -
- - {props.session.title} - - - {(summary) => ( -
- -
- )} -
-
-
- ) - - return ( -
- - {item} - - } - > - setState("hoverSession", open ? props.session.id : undefined)} - > - {language.t("session.messages.loading")}
} - > -
- { - if (!isActive()) { - layout.pendingMessage.set( - `${base64Encode(props.session.directory)}/${props.session.id}`, - message.id, - ) - navigate(`${props.slug}/session/${props.session.id}`) - return - } - window.history.replaceState(null, "", `#message-${message.id}`) - window.dispatchEvent(new HashChangeEvent("hashchange")) - }} - size="normal" - class="w-60" - /> -
- - - -
- - { - event.preventDefault() - event.stopPropagation() - void archiveSession(props.session) - }} - /> - -
-
- ) - } - - const NewSessionItem = (props: { slug: string; mobile?: boolean; dense?: boolean }): JSX.Element => { - const label = language.t("command.session.new") - const tooltip = () => props.mobile || !sidebarExpanded() - const item = ( - { - setState("hoverSession", undefined) - if (layout.sidebar.opened()) return - queueMicrotask(() => setState("hoverProject", undefined)) - }} - > -
-
- -
- - {label} - -
-
- ) - - return ( -
- - {item} - - } - > - {item} - -
- ) - } - - const SessionSkeleton = (props: { count?: number }): JSX.Element => { - const items = Array.from({ length: props.count ?? 4 }, (_, index) => index) - return ( -
- - {() =>
} - -
- ) - } - - const ProjectDragOverlay = (): JSX.Element => { - const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeProject)) - return ( - - {(p) => ( -
- -
- )} -
- ) - } - - const WorkspaceDragOverlay = (): JSX.Element => { - const label = createMemo(() => { - const project = sidebarProject() - if (!project) return - const directory = store.activeWorkspace - if (!directory) return - - const [workspaceStore] = globalSync.child(directory, { bootstrap: false }) - const kind = - directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") - const name = workspaceLabel(directory, workspaceStore.vcs?.branch, project.id) - return `${kind} : ${name}` - }) - - return ( - - {(value) => ( -
{value()}
- )} -
- ) - } - - const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => { - const sortable = createSortable(props.directory) - const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false }) - const [menu, setMenu] = createStore({ - open: false, - pendingRename: false, - }) - const slug = createMemo(() => base64Encode(props.directory)) - const sessions = createMemo(() => - workspaceStore.session - .filter((session) => session.directory === workspaceStore.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(sortSessions(Date.now())), - ) - const children = createMemo(() => { - const map = new Map() - for (const session of workspaceStore.session) { - if (!session.parentID) continue - const existing = map.get(session.parentID) - if (existing) { - existing.push(session.id) - continue - } - map.set(session.parentID, [session.id]) - } - return map - }) - const local = createMemo(() => props.directory === props.project.worktree) - const active = createMemo(() => { - const current = decode64(params.dir) ?? "" - return current === props.directory - }) - const workspaceValue = createMemo(() => { - const branch = workspaceStore.vcs?.branch - const name = branch ?? getFilename(props.directory) - return workspaceName(props.directory, props.project.id, branch) ?? name - }) - const open = createMemo(() => store.workspaceExpanded[props.directory] ?? local()) - const boot = createMemo(() => open() || active()) - const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false) - const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length) - const busy = createMemo(() => isBusy(props.directory)) - const wasBusy = createMemo((prev) => prev || busy(), false) - const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy()) - const loadMore = async () => { - setWorkspaceStore("limit", (limit) => limit + 5) - await globalSync.project.loadSessions(props.directory) - } - - const workspaceEditActive = createMemo(() => editorOpen(`workspace:${props.directory}`)) - - const openWrapper = (value: boolean) => { - setStore("workspaceExpanded", props.directory, value) - if (value) return - if (editorOpen(`workspace:${props.directory}`)) closeEditor() - } - - createEffect(() => { - if (!boot()) return - globalSync.child(props.directory, { bootstrap: true }) - }) - - const header = () => ( -
-
- }> - - -
- - {local() ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")} : - - - {workspaceStore.vcs?.branch ?? getFilename(props.directory)} - - } - > - { - const trimmed = next.trim() - if (!trimmed) return - renameWorkspace(props.directory, trimmed, props.project.id, workspaceStore.vcs?.branch) - setEditor("value", workspaceValue()) - }} - class="text-14-medium text-text-base min-w-0 truncate" - displayClass="text-14-medium text-text-base min-w-0 truncate" - editing={workspaceEditActive()} - stopPropagation={false} - openOnDblClick={false} - /> - - -
- ) - - return ( -
- -
-
-
- - {header()} - - } - > -
{header()}
-
-
- setMenu("open", open)} - > - - - - - { - if (!menu.pendingRename) return - event.preventDefault() - setMenu("pendingRename", false) - openEditor(`workspace:${props.directory}`, workspaceValue()) - }} - > - { - setMenu("pendingRename", true) - setMenu("open", false) - }} - > - {language.t("common.rename")} - - - dialog.show(() => ( - - )) - } - > - {language.t("common.reset")} - - - dialog.show(() => ( - - )) - } - > - {language.t("common.delete")} - - - - -
-
-
-
- - - - -
-
- ) - } - - const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { - const sortable = createSortable(props.project.worktree) - const selected = createMemo(() => { - const current = decode64(params.dir) ?? "" - return props.project.worktree === current || props.project.sandboxes?.includes(current) - }) - - const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2)) - const workspaceEnabled = createMemo( - () => props.project.vcs === "git" && layout.sidebar.workspaces(props.project.worktree)(), - ) - const [open, setOpen] = createSignal(false) - const [menu, setMenu] = createSignal(false) - - const preview = createMemo(() => !props.mobile && layout.sidebar.opened()) - const overlay = createMemo(() => !props.mobile && !layout.sidebar.opened()) - const active = createMemo( - () => menu() || (preview() ? open() : overlay() && state.hoverProject === props.project.worktree), - ) - - createEffect(() => { - if (preview()) return - if (!open()) return - setOpen(false) - }) - - const label = (directory: string) => { - const [data] = globalSync.child(directory, { bootstrap: false }) - const kind = - directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") - const name = workspaceLabel(directory, data.vcs?.branch, props.project.id) - return `${kind} : ${name}` - } - - const sessions = (directory: string) => { - const [data] = globalSync.child(directory, { bootstrap: false }) - const root = workspaceKey(directory) - return data.session - .filter((session) => workspaceKey(session.directory) === root) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(sortSessions(Date.now())) - .slice(0, 2) - } - - const projectSessions = () => { - const directory = props.project.worktree - const [data] = globalSync.child(directory, { bootstrap: false }) - const root = workspaceKey(directory) - return data.session - .filter((session) => workspaceKey(session.directory) === root) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(sortSessions(Date.now())) - .slice(0, 2) - } - - const projectName = () => props.project.name || getFilename(props.project.worktree) - const Trigger = () => ( - { - setMenu(value) - if (value) setOpen(false) - }} - > - { - if (!overlay()) return - aim.enter(props.project.worktree, event) - }} - onMouseLeave={() => { - if (!overlay()) return - aim.leave(props.project.worktree) - }} - onFocus={() => { - if (!overlay()) return - aim.activate(props.project.worktree) - }} - onClick={() => navigateToProject(props.project.worktree)} - onBlur={() => setOpen(false)} - > - - - - - dialog.show(() => )}> - {language.t("common.edit")} - - { - const enabled = layout.sidebar.workspaces(props.project.worktree)() - if (enabled) { - layout.sidebar.toggleWorkspaces(props.project.worktree) - return - } - if (props.project.vcs !== "git") return - layout.sidebar.toggleWorkspaces(props.project.worktree) - }} - > - - {layout.sidebar.workspaces(props.project.worktree)() - ? language.t("sidebar.workspaces.disable") - : language.t("sidebar.workspaces.enable")} - - - - closeProject(props.project.worktree)} - > - {language.t("common.close")} - - - - - ) - - return ( - // @ts-ignore -
- }> - } - onOpenChange={(value) => { - if (menu()) return - setOpen(value) - if (value) setState("hoverSession", undefined) - }} - > -
-
-
{displayName(props.project)}
- - { - event.stopPropagation() - setOpen(false) - closeProject(props.project.worktree) - }} - /> - -
-
{language.t("sidebar.project.recentSessions")}
-
- - {(session) => ( - - )} - - } - > - - {(directory) => ( -
-
-
- -
- {label(directory)} -
- - {(session) => ( - - )} - -
- )} -
-
-
-
- -
-
-
-
-
- ) - } - - const LocalWorkspace = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { - const workspace = createMemo(() => { - const [store, setStore] = globalSync.child(props.project.worktree) - return { store, setStore } - }) - const slug = createMemo(() => base64Encode(props.project.worktree)) - const sessions = createMemo(() => { - const store = workspace().store - return store.session - .filter((session) => session.directory === store.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(sortSessions(Date.now())) - }) - const children = createMemo(() => { - const store = workspace().store - const map = new Map() - for (const session of store.session) { - if (!session.parentID) continue - const existing = map.get(session.parentID) - if (existing) { - existing.push(session.id) - continue - } - map.set(session.parentID, [session.id]) - } - return map - }) - const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) - const loading = createMemo(() => !booted() && sessions().length === 0) - const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length) - const loadMore = async () => { - workspace().setStore("limit", (limit) => limit + 5) - await globalSync.project.loadSessions(props.project.worktree) - } - - return ( -
{ - if (!props.mobile) scrollContainerRef = el - }} - class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]" - > - -
- ) - } - const createWorkspace = async (project: LocalProject) => { if (!layout.sidebar.opened()) { setState("hoverSession", undefined) @@ -2568,7 +1563,7 @@ export default function Layout(props: ParentProps) { .catch((err) => { showToast({ title: language.t("workspace.create.failed.title"), - description: errorMessage(err), + description: errorMessage(err, language.t("common.requestFailed")), }) return undefined }) @@ -2602,6 +1597,65 @@ export default function Layout(props: ParentProps) { layout.mobileSidebar.hide() } + const workspaceSidebarCtx: WorkspaceSidebarContext = { + currentDir, + sidebarExpanded, + sidebarHovering, + nav: () => state.nav, + hoverSession: () => state.hoverSession, + setHoverSession, + clearHoverProjectSoon, + prefetchSession, + archiveSession, + workspaceName, + renameWorkspace, + editorOpen, + openEditor, + closeEditor, + setEditor, + InlineEditor, + isBusy, + workspaceExpanded: (directory, local) => workspaceOpenState(store.workspaceExpanded, directory, local), + setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", directory, value), + showResetWorkspaceDialog: (root, directory) => + dialog.show(() => ), + showDeleteWorkspaceDialog: (root, directory) => + dialog.show(() => ), + setScrollContainerRef: (el, mobile) => { + if (!mobile) scrollContainerRef = el + }, + } + + const projectSidebarCtx: ProjectSidebarContext = { + currentDir, + sidebarOpened: () => layout.sidebar.opened(), + sidebarHovering, + hoverProject: () => state.hoverProject, + nav: () => state.nav, + onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event), + onProjectMouseLeave: (worktree) => aim.leave(worktree), + onProjectFocus: (worktree) => aim.activate(worktree), + navigateToProject, + openSidebar: () => layout.sidebar.open(), + closeProject, + showEditProjectDialog, + toggleProjectWorkspaces, + workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(), + workspaceIds, + workspaceLabel, + sessionProps: { + sidebarExpanded, + sidebarHovering, + nav: () => state.nav, + hoverSession: () => state.hoverSession, + setHoverSession, + clearHoverProjectSoon, + prefetchSession, + archiveSession, + }, + setHoverSession, + } + const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => { const projectName = createMemo(() => { const project = panelProps.project @@ -2664,27 +1718,22 @@ export default function Layout(props: ParentProps) { variant="ghost" data-action="project-menu" data-project={base64Encode(p().worktree)} - class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active" + class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active" + classList={{ + "opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile, + }} aria-label={language.t("common.moreOptions")} /> - dialog.show(() => )}> + showEditProjectDialog(p())}> {language.t("common.edit")} { - const enabled = layout.sidebar.workspaces(p().worktree)() - if (enabled) { - layout.sidebar.toggleWorkspaces(p().worktree) - return - } - if (p().vcs !== "git") return - layout.sidebar.toggleWorkspaces(p().worktree) - }} + onSelect={() => toggleProjectWorkspaces(p())} > {layout.sidebar.workspaces(p().worktree)() @@ -2735,7 +1784,7 @@ export default function Layout(props: ParentProps) {
- +
} @@ -2770,13 +1819,22 @@ export default function Layout(props: ParentProps) { {(directory) => ( - + )}
- + store.activeWorkspace} + workspaceLabel={workspaceLabel} + />
@@ -2813,100 +1871,6 @@ export default function Layout(props: ParentProps) { ) } - const SidebarContent = (sidebarProps: { mobile?: boolean }) => { - const expanded = () => sidebarProps.mobile || layout.sidebar.opened() - - command.register(() => [ - { - id: "workspace.new", - title: language.t("workspace.new"), - category: language.t("command.category.workspace"), - keybind: "mod+shift+w", - disabled: !workspaceSetting(), - onSelect: () => { - const project = currentProject() - if (!project) return - return createWorkspace(project) - }, - }, - ]) - - return ( -
-
-
- - - -
- p.worktree)}> - - {(project) => } - - - - {language.t("command.project.open")} - - {command.keybind("project.open")} - -
- } - > - - -
- - - - -
-
- - - - - platform.openLink("https://opencode.ai/desktop-feedback")} - aria-label={language.t("sidebar.help")} - /> - -
-
- - - - -
- ) - } - return (
@@ -2940,7 +1904,27 @@ export default function Layout(props: ParentProps) { }} >
- + layout.sidebar.opened()} + aimMove={aim.move} + projects={() => layout.projects.list()} + renderProject={(project) => } + handleDragStart={handleDragStart} + handleDragEnd={handleDragEnd} + handleDragOver={handleDragOver} + openProjectLabel={language.t("command.project.open")} + openProjectKeybind={() => command.keybind("project.open")} + onOpenProject={chooseProject} + renderProjectOverlay={() => ( + layout.projects.list()} activeProject={() => store.activeProject} /> + )} + settingsLabel={() => language.t("sidebar.settings")} + settingsKeybind={() => command.keybind("settings.open")} + onOpenSettings={openSettings} + helpLabel={() => language.t("sidebar.help")} + onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} + renderPanel={() => } + />
{(project) => ( @@ -2982,7 +1966,28 @@ export default function Layout(props: ParentProps) { }} onClick={(e) => e.stopPropagation()} > - + layout.sidebar.opened()} + aimMove={aim.move} + projects={() => layout.projects.list()} + renderProject={(project) => } + handleDragStart={handleDragStart} + handleDragEnd={handleDragEnd} + handleDragOver={handleDragOver} + openProjectLabel={language.t("command.project.open")} + openProjectKeybind={() => command.keybind("project.open")} + onOpenProject={chooseProject} + renderProjectOverlay={() => ( + layout.projects.list()} activeProject={() => store.activeProject} /> + )} + settingsLabel={() => language.t("sidebar.settings")} + settingsKeybind={() => command.keybind("settings.open")} + onOpenSettings={openSettings} + helpLabel={() => language.t("sidebar.help")} + onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} + renderPanel={() => } + />
diff --git a/packages/app/src/pages/layout/deep-links.ts b/packages/app/src/pages/layout/deep-links.ts new file mode 100644 index 0000000000..772e6ece6b --- /dev/null +++ b/packages/app/src/pages/layout/deep-links.ts @@ -0,0 +1,26 @@ +export const deepLinkEvent = "opencode:deep-link" + +export const parseDeepLink = (input: string) => { + if (!input.startsWith("opencode://")) return + const url = new URL(input) + if (url.hostname !== "open-project") return + const directory = url.searchParams.get("directory") + if (!directory) return + return directory +} + +export const collectOpenProjectDeepLinks = (urls: string[]) => + urls.map(parseDeepLink).filter((directory): directory is string => !!directory) + +type OpenCodeWindow = Window & { + __OPENCODE__?: { + deepLinks?: string[] + } +} + +export const drainPendingDeepLinks = (target: OpenCodeWindow) => { + const pending = target.__OPENCODE__?.deepLinks ?? [] + if (pending.length === 0) return [] + if (target.__OPENCODE__) target.__OPENCODE__.deepLinks = [] + return pending +} diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts new file mode 100644 index 0000000000..8a8ea78c77 --- /dev/null +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test" +import { collectOpenProjectDeepLinks, drainPendingDeepLinks, parseDeepLink } from "./deep-links" +import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers" + +describe("layout deep links", () => { + test("parses open-project deep links", () => { + expect(parseDeepLink("opencode://open-project?directory=/tmp/demo")).toBe("/tmp/demo") + }) + + test("ignores non-project deep links", () => { + expect(parseDeepLink("opencode://other?directory=/tmp/demo")).toBeUndefined() + expect(parseDeepLink("https://example.com")).toBeUndefined() + }) + + test("collects only valid open-project directories", () => { + const result = collectOpenProjectDeepLinks([ + "opencode://open-project?directory=/a", + "opencode://other?directory=/b", + "opencode://open-project?directory=/c", + ]) + expect(result).toEqual(["/a", "/c"]) + }) + + test("drains global deep links once", () => { + const target = { + __OPENCODE__: { + deepLinks: ["opencode://open-project?directory=/a"], + }, + } as unknown as Window & { __OPENCODE__?: { deepLinks?: string[] } } + + expect(drainPendingDeepLinks(target)).toEqual(["opencode://open-project?directory=/a"]) + expect(drainPendingDeepLinks(target)).toEqual([]) + }) +}) + +describe("layout workspace helpers", () => { + test("normalizes trailing slash in workspace key", () => { + expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo") + expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:\\tmp\\demo") + }) + + test("keeps local first while preserving known order", () => { + const result = syncWorkspaceOrder("/root", ["/root", "/b", "/c"], ["/root", "/c", "/a", "/b"]) + expect(result).toEqual(["/root", "/c", "/b"]) + }) + + test("extracts draggable id safely", () => { + expect(getDraggableId({ draggable: { id: "x" } })).toBe("x") + expect(getDraggableId({ draggable: { id: 42 } })).toBeUndefined() + expect(getDraggableId(null)).toBeUndefined() + }) + + test("formats fallback project display name", () => { + expect(displayName({ worktree: "/tmp/app" })).toBe("app") + expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App") + }) + + test("extracts api error message and fallback", () => { + expect(errorMessage({ data: { message: "boom" } }, "fallback")).toBe("boom") + expect(errorMessage(new Error("broken"), "fallback")).toBe("broken") + expect(errorMessage("unknown", "fallback")).toBe("fallback") + }) +}) diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts new file mode 100644 index 0000000000..4d144f34ec --- /dev/null +++ b/packages/app/src/pages/layout/helpers.ts @@ -0,0 +1,65 @@ +import { getFilename } from "@opencode-ai/util/path" +import { type Session } from "@opencode-ai/sdk/v2/client" + +export const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "") + +export function sortSessions(now: number) { + const oneMinuteAgo = now - 60 * 1000 + return (a: Session, b: Session) => { + const aUpdated = a.time.updated ?? a.time.created + const bUpdated = b.time.updated ?? b.time.created + const aRecent = aUpdated > oneMinuteAgo + const bRecent = bUpdated > oneMinuteAgo + if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 + if (aRecent && !bRecent) return -1 + if (!aRecent && bRecent) return 1 + return bUpdated - aUpdated + } +} + +export const isRootVisibleSession = (session: Session, directory: string) => + workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived + +export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) => + store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).toSorted(sortSessions(now)) + +export const childMapByParent = (sessions: Session[]) => { + const map = new Map() + for (const session of sessions) { + if (!session.parentID) continue + const existing = map.get(session.parentID) + if (existing) { + existing.push(session.id) + continue + } + map.set(session.parentID, [session.id]) + } + return map +} + +export function getDraggableId(event: unknown): string | undefined { + if (typeof event !== "object" || event === null) return undefined + if (!("draggable" in event)) return undefined + const draggable = (event as { draggable?: { id?: unknown } }).draggable + if (!draggable) return undefined + return typeof draggable.id === "string" ? draggable.id : undefined +} + +export const displayName = (project: { name?: string; worktree: string }) => + project.name || getFilename(project.worktree) + +export const errorMessage = (err: unknown, fallback: string) => { + if (err && typeof err === "object" && "data" in err) { + const data = (err as { data?: { message?: string } }).data + if (data?.message) return data.message + } + if (err instanceof Error) return err.message + return fallback +} + +export const syncWorkspaceOrder = (local: string, dirs: string[], existing?: string[]) => { + if (!existing) return dirs + const keep = existing.filter((d) => d !== local && dirs.includes(d)) + const missing = dirs.filter((d) => d !== local && !existing.includes(d)) + return [local, ...missing, ...keep] +} diff --git a/packages/app/src/pages/layout/inline-editor.tsx b/packages/app/src/pages/layout/inline-editor.tsx new file mode 100644 index 0000000000..0bbfe244cc --- /dev/null +++ b/packages/app/src/pages/layout/inline-editor.tsx @@ -0,0 +1,113 @@ +import { createStore } from "solid-js/store" +import { Show, type Accessor } from "solid-js" +import { InlineInput } from "@opencode-ai/ui/inline-input" + +export function createInlineEditorController() { + const [editor, setEditor] = createStore({ + active: "" as string, + value: "", + }) + + const editorOpen = (id: string) => editor.active === id + const editorValue = () => editor.value + const openEditor = (id: string, value: string) => { + if (!id) return + setEditor({ active: id, value }) + } + const closeEditor = () => setEditor({ active: "", value: "" }) + + const saveEditor = (callback: (next: string) => void) => { + const next = editor.value.trim() + if (!next) { + closeEditor() + return + } + closeEditor() + callback(next) + } + + const editorKeyDown = (event: KeyboardEvent, callback: (next: string) => void) => { + if (event.key === "Enter") { + event.preventDefault() + saveEditor(callback) + return + } + if (event.key !== "Escape") return + event.preventDefault() + closeEditor() + } + + const InlineEditor = (props: { + id: string + value: Accessor + onSave: (next: string) => void + class?: string + displayClass?: string + editing?: boolean + stopPropagation?: boolean + openOnDblClick?: boolean + }) => { + const isEditing = () => props.editing ?? editorOpen(props.id) + const stopEvents = () => props.stopPropagation ?? false + const allowDblClick = () => props.openOnDblClick ?? true + const stopPropagation = (event: Event) => { + if (!stopEvents()) return + event.stopPropagation() + } + const handleDblClick = (event: MouseEvent) => { + if (!allowDblClick()) return + stopPropagation(event) + openEditor(props.id, props.value()) + } + + return ( + + {props.value()} + + } + > + { + requestAnimationFrame(() => el.focus()) + }} + value={editorValue()} + class={props.class} + onInput={(event) => setEditor("value", event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation() + editorKeyDown(event, props.onSave) + }} + onBlur={closeEditor} + onPointerDown={stopPropagation} + onClick={stopPropagation} + onDblClick={stopPropagation} + onMouseDown={stopPropagation} + onMouseUp={stopPropagation} + onTouchStart={stopPropagation} + /> + + ) + } + + return { + editor, + editorOpen, + editorValue, + openEditor, + closeEditor, + saveEditor, + editorKeyDown, + setEditor, + InlineEditor, + } +} diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx new file mode 100644 index 0000000000..facfbddc7f --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -0,0 +1,330 @@ +import { A, useNavigate, useParams } from "@solidjs/router" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" +import { useLayout, type LocalProject, getAvatarColors } from "@/context/layout" +import { useNotification } from "@/context/notification" +import { base64Encode } from "@opencode-ai/util/encode" +import { Avatar } from "@opencode-ai/ui/avatar" +import { DiffChanges } from "@opencode-ai/ui/diff-changes" +import { HoverCard } from "@opencode-ai/ui/hover-card" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { MessageNav } from "@opencode-ai/ui/message-nav" +import { Spinner } from "@opencode-ai/ui/spinner" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { getFilename } from "@opencode-ai/util/path" +import { type Message, type Session, type TextPart } from "@opencode-ai/sdk/v2/client" +import { For, Match, Show, Switch, createMemo, onCleanup, type Accessor, type JSX } from "solid-js" +import { agentColor } from "@/utils/agent" + +const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" + +export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { + const notification = useNotification() + const unseenCount = createMemo(() => notification.project.unseenCount(props.project.worktree)) + const hasError = createMemo(() => notification.project.unseenHasError(props.project.worktree)) + const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) + return ( +
+
+ 0 && props.notify }} + /> +
+ 0 && props.notify}> +
+ +
+ ) +} + +export type SessionItemProps = { + session: Session + slug: string + mobile?: boolean + dense?: boolean + popover?: boolean + children: Map + sidebarExpanded: Accessor + sidebarHovering: Accessor + nav: Accessor + hoverSession: Accessor + setHoverSession: (id: string | undefined) => void + clearHoverProjectSoon: () => void + prefetchSession: (session: Session, priority?: "high" | "low") => void + archiveSession: (session: Session) => Promise +} + +export const SessionItem = (props: SessionItemProps): JSX.Element => { + const params = useParams() + const navigate = useNavigate() + const layout = useLayout() + const language = useLanguage() + const notification = useNotification() + const globalSync = useGlobalSync() + const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id)) + const hasError = createMemo(() => notification.session.unseenHasError(props.session.id)) + const [sessionStore] = globalSync.child(props.session.directory) + const hasPermissions = createMemo(() => { + const permissions = sessionStore.permission?.[props.session.id] ?? [] + if (permissions.length > 0) return true + + for (const id of props.children.get(props.session.id) ?? []) { + const childPermissions = sessionStore.permission?.[id] ?? [] + if (childPermissions.length > 0) return true + } + return false + }) + const isWorking = createMemo(() => { + if (hasPermissions()) return false + const status = sessionStore.session_status[props.session.id] + return status?.type === "busy" || status?.type === "retry" + }) + + const tint = createMemo(() => { + const messages = sessionStore.message[props.session.id] + if (!messages) return undefined + let user: Message | undefined + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (message.role !== "user") continue + user = message + break + } + if (!user?.agent) return undefined + + const agent = sessionStore.agent.find((a) => a.name === user.agent) + return agentColor(user.agent, 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 && props.sidebarExpanded()) + const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) + const isActive = createMemo(() => props.session.id === params.id) + + const hoverPrefetch = { current: undefined as ReturnType | undefined } + const cancelHoverPrefetch = () => { + if (hoverPrefetch.current === undefined) return + clearTimeout(hoverPrefetch.current) + hoverPrefetch.current = undefined + } + const scheduleHoverPrefetch = () => { + if (hoverPrefetch.current !== undefined) return + hoverPrefetch.current = setTimeout(() => { + hoverPrefetch.current = undefined + props.prefetchSession(props.session) + }, 200) + } + + onCleanup(cancelHoverPrefetch) + + 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 = ( + props.prefetchSession(props.session, "high")} + onClick={() => { + props.setHoverSession(undefined) + if (layout.sidebar.opened()) return + props.clearHoverProjectSoon() + }} + > +
+
+ }> + + + + +
+ + +
+ + 0}> +
+ + +
+ + {props.session.title} + + + {(summary) => ( +
+ +
+ )} +
+
+
+ ) + + return ( +
+ + {item} + + } + > + props.setHoverSession(open ? props.session.id : undefined)} + > + {language.t("session.messages.loading")}
} + > +
+ { + if (!isActive()) { + layout.pendingMessage.set( + `${base64Encode(props.session.directory)}/${props.session.id}`, + message.id, + ) + navigate(`${props.slug}/session/${props.session.id}`) + return + } + window.history.replaceState(null, "", `#message-${message.id}`) + window.dispatchEvent(new HashChangeEvent("hashchange")) + }} + size="normal" + class="w-60" + /> +
+ + + +
+ + { + event.preventDefault() + event.stopPropagation() + void props.archiveSession(props.session) + }} + /> + +
+
+ ) +} + +export const NewSessionItem = (props: { + slug: string + mobile?: boolean + dense?: boolean + sidebarExpanded: Accessor + clearHoverProjectSoon: () => void + setHoverSession: (id: string | undefined) => void +}): JSX.Element => { + const layout = useLayout() + const language = useLanguage() + const label = language.t("command.session.new") + const tooltip = () => props.mobile || !props.sidebarExpanded() + const item = ( + { + props.setHoverSession(undefined) + if (layout.sidebar.opened()) return + props.clearHoverProjectSoon() + }} + > +
+
+ +
+ + {label} + +
+
+ ) + + return ( +
+ + {item} + + } + > + {item} + +
+ ) +} + +export const SessionSkeleton = (props: { count?: number }): JSX.Element => { + const items = Array.from({ length: props.count ?? 4 }, (_, index) => index) + return ( +
+ + {() =>
} + +
+ ) +} diff --git a/packages/app/src/pages/layout/sidebar-project-helpers.test.ts b/packages/app/src/pages/layout/sidebar-project-helpers.test.ts new file mode 100644 index 0000000000..75958d49e9 --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-project-helpers.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test" +import { projectSelected, projectTileActive } from "./sidebar-project-helpers" + +describe("projectSelected", () => { + test("matches direct worktree", () => { + expect(projectSelected("/tmp/root", "/tmp/root")).toBe(true) + }) + + test("matches sandbox worktree", () => { + expect(projectSelected("/tmp/branch", "/tmp/root", ["/tmp/branch"])).toBe(true) + expect(projectSelected("/tmp/other", "/tmp/root", ["/tmp/branch"])).toBe(false) + }) +}) + +describe("projectTileActive", () => { + test("menu state always wins", () => { + expect( + projectTileActive({ + menu: true, + preview: false, + open: false, + overlay: false, + worktree: "/tmp/root", + }), + ).toBe(true) + }) + + test("preview mode uses open state", () => { + expect( + projectTileActive({ + menu: false, + preview: true, + open: true, + overlay: true, + hoverProject: "/tmp/other", + worktree: "/tmp/root", + }), + ).toBe(true) + }) + + test("overlay mode uses hovered project", () => { + expect( + projectTileActive({ + menu: false, + preview: false, + open: false, + overlay: true, + hoverProject: "/tmp/root", + worktree: "/tmp/root", + }), + ).toBe(true) + expect( + projectTileActive({ + menu: false, + preview: false, + open: false, + overlay: true, + hoverProject: "/tmp/other", + worktree: "/tmp/root", + }), + ).toBe(false) + }) +}) diff --git a/packages/app/src/pages/layout/sidebar-project-helpers.ts b/packages/app/src/pages/layout/sidebar-project-helpers.ts new file mode 100644 index 0000000000..06d38a3cd1 --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-project-helpers.ts @@ -0,0 +1,11 @@ +export const projectSelected = (currentDir: string, worktree: string, sandboxes?: string[]) => + worktree === currentDir || sandboxes?.includes(currentDir) === true + +export const projectTileActive = (args: { + menu: boolean + preview: boolean + open: boolean + overlay: boolean + hoverProject?: string + worktree: string +}) => args.menu || (args.preview ? args.open : args.overlay && args.hoverProject === args.worktree) diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx new file mode 100644 index 0000000000..c91dc987d8 --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -0,0 +1,283 @@ +import { createEffect, createMemo, createSignal, For, Show, type Accessor, type JSX } from "solid-js" +import { base64Encode } from "@opencode-ai/util/encode" +import { Button } from "@opencode-ai/ui/button" +import { ContextMenu } from "@opencode-ai/ui/context-menu" +import { HoverCard } from "@opencode-ai/ui/hover-card" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { createSortable } from "@thisbeyond/solid-dnd" +import { type LocalProject } from "@/context/layout" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" +import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items" +import { childMapByParent, displayName, sortedRootSessions } from "./helpers" +import { projectSelected, projectTileActive } from "./sidebar-project-helpers" + +export type ProjectSidebarContext = { + currentDir: Accessor + sidebarOpened: Accessor + sidebarHovering: Accessor + hoverProject: Accessor + nav: Accessor + onProjectMouseEnter: (worktree: string, event: MouseEvent) => void + onProjectMouseLeave: (worktree: string) => void + onProjectFocus: (worktree: string) => void + navigateToProject: (directory: string) => void + openSidebar: () => void + closeProject: (directory: string) => void + showEditProjectDialog: (project: LocalProject) => void + toggleProjectWorkspaces: (project: LocalProject) => void + workspacesEnabled: (project: LocalProject) => boolean + workspaceIds: (project: LocalProject) => string[] + workspaceLabel: (directory: string, branch?: string, projectId?: string) => string + sessionProps: Omit + setHoverSession: (id: string | undefined) => void +} + +export const ProjectDragOverlay = (props: { + projects: Accessor + activeProject: Accessor +}): JSX.Element => { + const project = createMemo(() => props.projects().find((p) => p.worktree === props.activeProject())) + return ( + + {(p) => ( +
+ +
+ )} +
+ ) +} + +export const SortableProject = (props: { + project: LocalProject + mobile?: boolean + ctx: ProjectSidebarContext +}): JSX.Element => { + const globalSync = useGlobalSync() + const language = useLanguage() + const sortable = createSortable(props.project.worktree) + const selected = createMemo(() => + projectSelected(props.ctx.currentDir(), props.project.worktree, props.project.sandboxes), + ) + const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2)) + const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project)) + const [open, setOpen] = createSignal(false) + const [menu, setMenu] = createSignal(false) + + const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened()) + const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened()) + const active = createMemo(() => + projectTileActive({ + menu: menu(), + preview: preview(), + open: open(), + overlay: overlay(), + hoverProject: props.ctx.hoverProject(), + worktree: props.project.worktree, + }), + ) + + createEffect(() => { + if (preview()) return + if (!open()) return + setOpen(false) + }) + + const label = (directory: string) => { + const [data] = globalSync.child(directory, { bootstrap: false }) + const kind = + directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") + const name = props.ctx.workspaceLabel(directory, data.vcs?.branch, props.project.id) + return `${kind} : ${name}` + } + + const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0]) + const projectSessions = createMemo(() => sortedRootSessions(projectStore(), Date.now()).slice(0, 2)) + const projectChildren = createMemo(() => childMapByParent(projectStore().session)) + const workspaceSessions = (directory: string) => { + const [data] = globalSync.child(directory, { bootstrap: false }) + return sortedRootSessions(data, Date.now()).slice(0, 2) + } + const workspaceChildren = (directory: string) => { + const [data] = globalSync.child(directory, { bootstrap: false }) + return childMapByParent(data.session) + } + + const Trigger = () => ( + { + setMenu(value) + if (value) setOpen(false) + }} + > + { + if (!overlay()) return + props.ctx.onProjectMouseEnter(props.project.worktree, event) + }} + onMouseLeave={() => { + if (!overlay()) return + props.ctx.onProjectMouseLeave(props.project.worktree) + }} + onFocus={() => { + if (!overlay()) return + props.ctx.onProjectFocus(props.project.worktree) + }} + onClick={() => props.ctx.navigateToProject(props.project.worktree)} + onBlur={() => setOpen(false)} + > + + + + + props.ctx.showEditProjectDialog(props.project)}> + {language.t("common.edit")} + + props.ctx.toggleProjectWorkspaces(props.project)} + > + + {props.ctx.workspacesEnabled(props.project) + ? language.t("sidebar.workspaces.disable") + : language.t("sidebar.workspaces.enable")} + + + + props.ctx.closeProject(props.project.worktree)} + > + {language.t("common.close")} + + + + + ) + + return ( + // @ts-ignore +
+ }> + } + onOpenChange={(value) => { + if (menu()) return + setOpen(value) + if (value) props.ctx.setHoverSession(undefined) + }} + > +
+
+
{displayName(props.project)}
+ + { + event.stopPropagation() + setOpen(false) + props.ctx.closeProject(props.project.worktree) + }} + /> + +
+
{language.t("sidebar.project.recentSessions")}
+
+ + {(session) => ( + + )} + + } + > + + {(directory) => { + const sessions = createMemo(() => workspaceSessions(directory)) + const children = createMemo(() => workspaceChildren(directory)) + return ( +
+
+
+ +
+ {label(directory)} +
+ + {(session) => ( + + )} + +
+ ) + }} +
+
+
+
+ +
+
+
+
+
+ ) +} diff --git a/packages/app/src/pages/layout/sidebar-shell-helpers.ts b/packages/app/src/pages/layout/sidebar-shell-helpers.ts new file mode 100644 index 0000000000..93c286c152 --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-shell-helpers.ts @@ -0,0 +1 @@ +export const sidebarExpanded = (mobile: boolean | undefined, opened: boolean) => !!mobile || opened diff --git a/packages/app/src/pages/layout/sidebar-shell.test.ts b/packages/app/src/pages/layout/sidebar-shell.test.ts new file mode 100644 index 0000000000..694025a653 --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-shell.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test" +import { sidebarExpanded } from "./sidebar-shell-helpers" + +describe("sidebarExpanded", () => { + test("expands on mobile regardless of desktop open state", () => { + expect(sidebarExpanded(true, false)).toBe(true) + }) + + test("follows desktop open state when not mobile", () => { + expect(sidebarExpanded(false, true)).toBe(true) + expect(sidebarExpanded(false, false)).toBe(false) + }) +}) diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx new file mode 100644 index 0000000000..ce96a09d11 --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-shell.tsx @@ -0,0 +1,109 @@ +import { createMemo, For, Show, type Accessor, type JSX } from "solid-js" +import { + DragDropProvider, + DragDropSensors, + DragOverlay, + SortableProvider, + closestCenter, + type DragEvent, +} from "@thisbeyond/solid-dnd" +import { ConstrainDragXAxis } from "@/utils/solid-dnd" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { type LocalProject } from "@/context/layout" +import { sidebarExpanded } from "./sidebar-shell-helpers" + +export const SidebarContent = (props: { + mobile?: boolean + opened: Accessor + aimMove: (event: MouseEvent) => void + projects: Accessor + renderProject: (project: LocalProject) => JSX.Element + handleDragStart: (event: unknown) => void + handleDragEnd: () => void + handleDragOver: (event: DragEvent) => void + openProjectLabel: JSX.Element + openProjectKeybind: Accessor + onOpenProject: () => void + renderProjectOverlay: () => JSX.Element + settingsLabel: Accessor + settingsKeybind: Accessor + onOpenSettings: () => void + helpLabel: Accessor + onOpenHelp: () => void + renderPanel: () => JSX.Element +}): JSX.Element => { + const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened())) + + return ( +
+
+
+ + + +
+ p.worktree)}> + {(project) => props.renderProject(project)} + + + {props.openProjectLabel} + + {props.openProjectKeybind()} + +
+ } + > + + +
+ {props.renderProjectOverlay()} + +
+
+ + + + + + +
+
+ + {props.renderPanel()} +
+ ) +} diff --git a/packages/app/src/pages/layout/sidebar-workspace-helpers.ts b/packages/app/src/pages/layout/sidebar-workspace-helpers.ts new file mode 100644 index 0000000000..aa7cb480e5 --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-workspace-helpers.ts @@ -0,0 +1,2 @@ +export const workspaceOpenState = (expanded: Record, directory: string, local: boolean) => + expanded[directory] ?? local diff --git a/packages/app/src/pages/layout/sidebar-workspace.test.ts b/packages/app/src/pages/layout/sidebar-workspace.test.ts new file mode 100644 index 0000000000..d71c39fc8b --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-workspace.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test" +import { workspaceOpenState } from "./sidebar-workspace-helpers" + +describe("workspaceOpenState", () => { + test("defaults to local workspace open", () => { + expect(workspaceOpenState({}, "/tmp/root", true)).toBe(true) + }) + + test("uses persisted expansion state when present", () => { + expect(workspaceOpenState({ "/tmp/root": false }, "/tmp/root", true)).toBe(false) + expect(workspaceOpenState({ "/tmp/branch": true }, "/tmp/branch", false)).toBe(true) + }) +}) diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx new file mode 100644 index 0000000000..11bad84b02 --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -0,0 +1,387 @@ +import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js" +import { createStore } from "solid-js/store" +import { createSortable } from "@thisbeyond/solid-dnd" +import { base64Encode } from "@opencode-ai/util/encode" +import { getFilename } from "@opencode-ai/util/path" +import { Button } from "@opencode-ai/ui/button" +import { Collapsible } from "@opencode-ai/ui/collapsible" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Spinner } from "@opencode-ai/ui/spinner" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { type Session } from "@opencode-ai/sdk/v2/client" +import { type LocalProject } from "@/context/layout" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" +import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items" +import { childMapByParent, sortedRootSessions } from "./helpers" + +type InlineEditorComponent = (props: { + id: string + value: Accessor + onSave: (next: string) => void + class?: string + displayClass?: string + editing?: boolean + stopPropagation?: boolean + openOnDblClick?: boolean +}) => JSX.Element + +export type WorkspaceSidebarContext = { + currentDir: Accessor + sidebarExpanded: Accessor + sidebarHovering: Accessor + nav: Accessor + hoverSession: Accessor + setHoverSession: (id: string | undefined) => void + clearHoverProjectSoon: () => void + prefetchSession: (session: Session, priority?: "high" | "low") => void + archiveSession: (session: Session) => Promise + workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined + renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void + editorOpen: (id: string) => boolean + openEditor: (id: string, value: string) => void + closeEditor: () => void + setEditor: (key: "value", value: string) => void + InlineEditor: InlineEditorComponent + isBusy: (directory: string) => boolean + workspaceExpanded: (directory: string, local: boolean) => boolean + setWorkspaceExpanded: (directory: string, value: boolean) => void + showResetWorkspaceDialog: (root: string, directory: string) => void + showDeleteWorkspaceDialog: (root: string, directory: string) => void + setScrollContainerRef: (el: HTMLDivElement | undefined, mobile?: boolean) => void +} + +export const WorkspaceDragOverlay = (props: { + sidebarProject: Accessor + activeWorkspace: Accessor + workspaceLabel: (directory: string, branch?: string, projectId?: string) => string +}): JSX.Element => { + const globalSync = useGlobalSync() + const language = useLanguage() + const label = createMemo(() => { + const project = props.sidebarProject() + if (!project) return + const directory = props.activeWorkspace() + if (!directory) return + + const [workspaceStore] = globalSync.child(directory, { bootstrap: false }) + const kind = + directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") + const name = props.workspaceLabel(directory, workspaceStore.vcs?.branch, project.id) + return `${kind} : ${name}` + }) + + return ( + + {(value) =>
{value()}
} +
+ ) +} + +export const SortableWorkspace = (props: { + ctx: WorkspaceSidebarContext + directory: string + project: LocalProject + mobile?: boolean +}): JSX.Element => { + const globalSync = useGlobalSync() + const language = useLanguage() + const sortable = createSortable(props.directory) + const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false }) + const [menu, setMenu] = createStore({ + open: false, + pendingRename: false, + }) + const slug = createMemo(() => base64Encode(props.directory)) + const sessions = createMemo(() => sortedRootSessions(workspaceStore, Date.now())) + const children = createMemo(() => childMapByParent(workspaceStore.session)) + const local = createMemo(() => props.directory === props.project.worktree) + const active = createMemo(() => props.ctx.currentDir() === props.directory) + const workspaceValue = createMemo(() => { + const branch = workspaceStore.vcs?.branch + const name = branch ?? getFilename(props.directory) + return props.ctx.workspaceName(props.directory, props.project.id, branch) ?? name + }) + const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local())) + const boot = createMemo(() => open() || active()) + const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false) + const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length) + const busy = createMemo(() => props.ctx.isBusy(props.directory)) + const wasBusy = createMemo((prev) => prev || busy(), false) + const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy()) + const loadMore = async () => { + setWorkspaceStore("limit", (limit) => limit + 5) + await globalSync.project.loadSessions(props.directory) + } + + const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`)) + + const openWrapper = (value: boolean) => { + props.ctx.setWorkspaceExpanded(props.directory, value) + if (value) return + if (props.ctx.editorOpen(`workspace:${props.directory}`)) props.ctx.closeEditor() + } + + createEffect(() => { + if (!boot()) return + globalSync.child(props.directory, { bootstrap: true }) + }) + + const header = () => ( +
+
+ }> + + +
+ + {local() ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")} : + + + {workspaceStore.vcs?.branch ?? getFilename(props.directory)} + + } + > + { + const trimmed = next.trim() + if (!trimmed) return + props.ctx.renameWorkspace(props.directory, trimmed, props.project.id, workspaceStore.vcs?.branch) + props.ctx.setEditor("value", workspaceValue()) + }} + class="text-14-medium text-text-base min-w-0 truncate" + displayClass="text-14-medium text-text-base min-w-0 truncate" + editing={workspaceEditActive()} + stopPropagation={false} + openOnDblClick={false} + /> + + +
+ ) + + return ( +
+ +
+
+
+ + {header()} + + } + > +
{header()}
+
+
+ setMenu("open", open)} + > + + + + + { + if (!menu.pendingRename) return + event.preventDefault() + setMenu("pendingRename", false) + props.ctx.openEditor(`workspace:${props.directory}`, workspaceValue()) + }} + > + { + setMenu("pendingRename", true) + setMenu("open", false) + }} + > + {language.t("common.rename")} + + props.ctx.showResetWorkspaceDialog(props.project.worktree, props.directory)} + > + {language.t("common.reset")} + + props.ctx.showDeleteWorkspaceDialog(props.project.worktree, props.directory)} + > + {language.t("common.delete")} + + + + +
+
+
+
+ + + + +
+
+ ) +} + +export const LocalWorkspace = (props: { + ctx: WorkspaceSidebarContext + project: LocalProject + mobile?: boolean +}): JSX.Element => { + const globalSync = useGlobalSync() + const language = useLanguage() + const workspace = createMemo(() => { + const [store, setStore] = globalSync.child(props.project.worktree) + return { store, setStore } + }) + const slug = createMemo(() => base64Encode(props.project.worktree)) + const sessions = createMemo(() => sortedRootSessions(workspace().store, Date.now())) + const children = createMemo(() => childMapByParent(workspace().store.session)) + const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) + const loading = createMemo(() => !booted() && sessions().length === 0) + const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length) + const loadMore = async () => { + workspace().setStore("limit", (limit) => limit + 5) + await globalSync.project.loadSessions(props.project.worktree) + } + + return ( +
props.ctx.setScrollContainerRef(el, props.mobile)} + class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]" + > + +
+ ) +} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index cb07c3b47a..a70d4e8a27 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,82 +1,63 @@ -import { - For, - onCleanup, - onMount, - Show, - Match, - Switch, - createMemo, - createEffect, - createSignal, - on, - type JSX, -} from "solid-js" +import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js" import { createMediaQuery } from "@solid-primitives/media" import { createResizeObserver } from "@solid-primitives/resize-observer" import { Dynamic } from "solid-js/web" import { useLocal } from "@/context/local" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" import { createStore, produce } from "solid-js/store" -import { PromptInput } from "@/components/prompt-input" import { SessionContextUsage } from "@/components/session-context-usage" import { IconButton } from "@opencode-ai/ui/icon-button" import { Button } from "@opencode-ai/ui/button" -import { Icon } from "@opencode-ai/ui/icon" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Dialog } from "@opencode-ai/ui/dialog" -import { InlineInput } from "@opencode-ai/ui/inline-input" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Tabs } from "@opencode-ai/ui/tabs" import { Select } from "@opencode-ai/ui/select" import { useCodeComponent } from "@opencode-ai/ui/context/code" -import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment" -import { SessionTurn } from "@opencode-ai/ui/session-turn" -import { BasicTool } from "@opencode-ai/ui/basic-tool" import { createAutoScroll } from "@opencode-ai/ui/hooks" -import { SessionReview } from "@opencode-ai/ui/session-review" import { Mark } from "@opencode-ai/ui/logo" -import { QuestionDock } from "@/components/question-dock" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" import { useSync } from "@/context/sync" import { useTerminal, type LocalPTY } from "@/context/terminal" import { useLayout } from "@/context/layout" -import { Terminal } from "@/components/terminal" import { checksum, base64Encode } from "@opencode-ai/util/encode" import { findLast } from "@opencode-ai/util/array" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" import FileTree from "@/components/file-tree" -import { DialogSelectModel } from "@/components/dialog-select-model" -import { DialogSelectMcp } from "@/components/dialog-select-mcp" -import { DialogFork } from "@/components/dialog-fork" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useNavigate, useParams } from "@solidjs/router" import { UserMessage } from "@opencode-ai/sdk/v2" -import type { FileDiff } from "@opencode-ai/sdk/v2" import { useSDK } from "@/context/sdk" import { usePrompt } from "@/context/prompt" -import { useComments, type LineComment } from "@/context/comments" -import { extractPromptFromParts } from "@/utils/prompt" +import { useComments } from "@/context/comments" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { usePermission } from "@/context/permission" -import { decode64 } from "@/utils/base64" import { showToast } from "@opencode-ai/ui/toast" -import { - SessionHeader, - SessionContextTab, - SortableTab, - FileVisual, - SortableTerminalTab, - NewSessionView, -} from "@/components/session" +import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session" import { navMark, navParams } from "@/utils/perf" import { same } from "@/utils/same" - -type DiffStyle = "unified" | "split" +import { createOpenReviewFile, focusTerminalById } from "@/pages/session/helpers" +import { createScrollSpy } from "@/pages/session/scroll-spy" +import { createFileTabListSync } from "@/pages/session/file-tab-scroll" +import { FileTabContent } from "@/pages/session/file-tabs" +import { + SessionReviewTab, + StickyAddButton, + type DiffStyle, + type SessionReviewTabProps, +} from "@/pages/session/review-tab" +import { TerminalPanel } from "@/pages/session/terminal-panel" +import { terminalTabLabel } from "@/pages/session/terminal-label" +import { MessageTimeline } from "@/pages/session/message-timeline" +import { useSessionCommands } from "@/pages/session/use-session-commands" +import { SessionPromptDock } from "@/pages/session/session-prompt-dock" +import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs" +import { SessionSidePanel } from "@/pages/session/session-side-panel" +import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" type HandoffSession = { prompt: string @@ -105,155 +86,6 @@ const setSessionHandoff = (key: string, patch: Partial) => { touch(handoff.session, key, { ...prev, ...patch }) } -interface SessionReviewTabProps { - title?: JSX.Element - empty?: JSX.Element - diffs: () => FileDiff[] - view: () => ReturnType["view"]> - diffStyle: DiffStyle - onDiffStyleChange?: (style: DiffStyle) => void - onViewFile?: (file: string) => void - onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void - comments?: LineComment[] - focusedComment?: { file: string; id: string } | null - onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void - focusedFile?: string - onScrollRef?: (el: HTMLDivElement) => void - classes?: { - root?: string - header?: string - container?: string - } -} - -function StickyAddButton(props: { children: JSX.Element }) { - const [stuck, setStuck] = createSignal(false) - let button: HTMLDivElement | undefined - - createEffect(() => { - const node = button - if (!node) return - - const scroll = node.parentElement - if (!scroll) return - - const handler = () => { - const rect = node.getBoundingClientRect() - const scrollRect = scroll.getBoundingClientRect() - setStuck(rect.right >= scrollRect.right && scroll.scrollWidth > scroll.clientWidth) - } - - scroll.addEventListener("scroll", handler, { passive: true }) - const observer = new ResizeObserver(handler) - observer.observe(scroll) - handler() - onCleanup(() => { - scroll.removeEventListener("scroll", handler) - observer.disconnect() - }) - }) - - return ( -
- {props.children} -
- ) -} - -function SessionReviewTab(props: SessionReviewTabProps) { - let scroll: HTMLDivElement | undefined - let frame: number | undefined - let pending: { x: number; y: number } | undefined - - const sdk = useSDK() - - const readFile = async (path: string) => { - return sdk.client.file - .read({ path }) - .then((x) => x.data) - .catch(() => undefined) - } - - const restoreScroll = () => { - const el = scroll - if (!el) return - - const s = props.view().scroll("review") - if (!s) return - - if (el.scrollTop !== s.y) el.scrollTop = s.y - if (el.scrollLeft !== s.x) el.scrollLeft = s.x - } - - const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { - pending = { - x: event.currentTarget.scrollLeft, - y: event.currentTarget.scrollTop, - } - if (frame !== undefined) return - - frame = requestAnimationFrame(() => { - frame = undefined - - const next = pending - pending = undefined - if (!next) return - - props.view().setScroll("review", next) - }) - } - - createEffect( - on( - () => props.diffs().length, - () => { - requestAnimationFrame(restoreScroll) - }, - { defer: true }, - ), - ) - - onCleanup(() => { - if (frame === undefined) return - cancelAnimationFrame(frame) - }) - - return ( - { - scroll = el - props.onScrollRef?.(el) - restoreScroll() - }} - onScroll={handleScroll} - onDiffRendered={() => requestAnimationFrame(restoreScroll)} - open={props.view().review.open()} - onOpenChange={props.view().review.setOpen} - classes={{ - root: props.classes?.root ?? "pb-40", - header: props.classes?.header ?? "px-6", - container: props.classes?.container ?? "px-6", - }} - diffs={props.diffs()} - diffStyle={props.diffStyle} - onDiffStyleChange={props.onDiffStyleChange} - onViewFile={props.onViewFile} - focusedFile={props.focusedFile} - readFile={readFile} - onLineComment={props.onLineComment} - comments={props.comments} - focusedComment={props.focusedComment} - onFocusedCommentChange={props.onFocusedCommentChange} - /> - ) -} - export default function Page() { const layout = useLayout() const local = useLocal() @@ -818,8 +650,6 @@ export default function Page() { const scrollGestureWindowMs = 250 - let touchGesture: number | undefined - const markScrollGesture = (target?: EventTarget | null) => { const root = scroller if (!root) return @@ -872,19 +702,7 @@ export default function Page() { if (document.activeElement instanceof HTMLElement) { document.activeElement.blur() } - const wrapper = document.getElementById(`terminal-wrapper-${activeId}`) - const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement - if (!element) return - - // Find and focus the ghostty textarea (the actual input element) - const textarea = element.querySelector("textarea") as HTMLTextAreaElement - if (textarea) { - textarea.focus() - return - } - // Fallback: focus container and dispatch pointer event - element.focus() - element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true })) + focusTerminalById(activeId) }, ), ) @@ -973,366 +791,6 @@ export default function Page() { }) } - command.register(() => [ - { - id: "session.new", - title: language.t("command.session.new"), - category: language.t("command.category.session"), - keybind: "mod+shift+s", - slash: "new", - onSelect: () => navigate(`/${params.dir}/session`), - }, - { - id: "file.open", - title: language.t("command.file.open"), - description: language.t("palette.search.placeholder"), - category: language.t("command.category.file"), - keybind: "mod+p", - slash: "open", - onSelect: () => dialog.show(() => showAllFiles()} />), - }, - { - id: "tab.close", - title: language.t("command.tab.close"), - category: language.t("command.category.file"), - keybind: "mod+w", - disabled: !tabs().active(), - onSelect: () => { - const active = tabs().active() - if (!active) return - tabs().close(active) - }, - }, - { - id: "context.addSelection", - title: language.t("command.context.addSelection"), - description: language.t("command.context.addSelection.description"), - category: language.t("command.category.context"), - keybind: "mod+shift+l", - disabled: (() => { - const active = tabs().active() - if (!active) return true - const path = file.pathFromTab(active) - if (!path) return true - return file.selectedLines(path) == null - })(), - onSelect: () => { - const active = tabs().active() - if (!active) return - const path = file.pathFromTab(active) - if (!path) return - - const range = file.selectedLines(path) - if (!range) { - showToast({ - title: language.t("toast.context.noLineSelection.title"), - description: language.t("toast.context.noLineSelection.description"), - }) - return - } - - addSelectionToContext(path, selectionFromLines(range)) - }, - }, - { - id: "terminal.toggle", - title: language.t("command.terminal.toggle"), - description: "", - category: language.t("command.category.view"), - keybind: "ctrl+`", - slash: "terminal", - onSelect: () => view().terminal.toggle(), - }, - { - id: "review.toggle", - title: language.t("command.review.toggle"), - description: "", - category: language.t("command.category.view"), - keybind: "mod+shift+r", - onSelect: () => view().reviewPanel.toggle(), - }, - { - id: "fileTree.toggle", - title: language.t("command.fileTree.toggle"), - description: "", - category: language.t("command.category.view"), - onSelect: () => { - const opening = !layout.fileTree.opened() - if (opening && !view().reviewPanel.opened()) view().reviewPanel.open() - layout.fileTree.toggle() - }, - }, - { - id: "terminal.new", - title: language.t("command.terminal.new"), - description: language.t("command.terminal.new.description"), - category: language.t("command.category.terminal"), - keybind: "ctrl+alt+t", - onSelect: () => { - if (terminal.all().length > 0) terminal.new() - view().terminal.open() - }, - }, - { - id: "steps.toggle", - title: language.t("command.steps.toggle"), - description: language.t("command.steps.toggle.description"), - category: language.t("command.category.view"), - keybind: "mod+e", - slash: "steps", - disabled: !params.id, - onSelect: () => { - const msg = activeMessage() - if (!msg) return - setStore("expanded", msg.id, (open: boolean | undefined) => !open) - }, - }, - { - id: "message.previous", - title: language.t("command.message.previous"), - description: language.t("command.message.previous.description"), - category: language.t("command.category.session"), - keybind: "mod+arrowup", - disabled: !params.id, - onSelect: () => navigateMessageByOffset(-1), - }, - { - id: "message.next", - title: language.t("command.message.next"), - description: language.t("command.message.next.description"), - category: language.t("command.category.session"), - keybind: "mod+arrowdown", - disabled: !params.id, - onSelect: () => navigateMessageByOffset(1), - }, - { - id: "model.choose", - title: language.t("command.model.choose"), - description: language.t("command.model.choose.description"), - category: language.t("command.category.model"), - keybind: "mod+'", - slash: "model", - onSelect: () => dialog.show(() => ), - }, - { - id: "mcp.toggle", - title: language.t("command.mcp.toggle"), - description: language.t("command.mcp.toggle.description"), - category: language.t("command.category.mcp"), - keybind: "mod+;", - slash: "mcp", - onSelect: () => dialog.show(() => ), - }, - { - id: "agent.cycle", - title: language.t("command.agent.cycle"), - description: language.t("command.agent.cycle.description"), - category: language.t("command.category.agent"), - keybind: "mod+.", - slash: "agent", - onSelect: () => local.agent.move(1), - }, - { - id: "agent.cycle.reverse", - title: language.t("command.agent.cycle.reverse"), - description: language.t("command.agent.cycle.reverse.description"), - category: language.t("command.category.agent"), - keybind: "shift+mod+.", - onSelect: () => local.agent.move(-1), - }, - { - id: "model.variant.cycle", - title: language.t("command.model.variant.cycle"), - description: language.t("command.model.variant.cycle.description"), - category: language.t("command.category.model"), - keybind: "shift+mod+d", - onSelect: () => { - local.model.variant.cycle() - }, - }, - { - id: "permissions.autoaccept", - title: - params.id && permission.isAutoAccepting(params.id, sdk.directory) - ? language.t("command.permissions.autoaccept.disable") - : language.t("command.permissions.autoaccept.enable"), - category: language.t("command.category.permissions"), - keybind: "mod+shift+a", - disabled: !params.id || !permission.permissionsEnabled(), - onSelect: () => { - const sessionID = params.id - if (!sessionID) return - permission.toggleAutoAccept(sessionID, sdk.directory) - showToast({ - title: permission.isAutoAccepting(sessionID, sdk.directory) - ? language.t("toast.permissions.autoaccept.on.title") - : language.t("toast.permissions.autoaccept.off.title"), - description: permission.isAutoAccepting(sessionID, sdk.directory) - ? language.t("toast.permissions.autoaccept.on.description") - : language.t("toast.permissions.autoaccept.off.description"), - }) - }, - }, - { - id: "session.undo", - title: language.t("command.session.undo"), - description: language.t("command.session.undo.description"), - category: language.t("command.category.session"), - slash: "undo", - disabled: !params.id || visibleUserMessages().length === 0, - onSelect: async () => { - const sessionID = params.id - if (!sessionID) return - if (status()?.type !== "idle") { - await sdk.client.session.abort({ sessionID }).catch(() => {}) - } - const revert = info()?.revert?.messageID - // Find the last user message that's not already reverted - const message = findLast(userMessages(), (x) => !revert || x.id < revert) - if (!message) return - await sdk.client.session.revert({ sessionID, messageID: message.id }) - // Restore the prompt from the reverted message - const parts = sync.data.part[message.id] - if (parts) { - const restored = extractPromptFromParts(parts, { directory: sdk.directory }) - prompt.set(restored) - } - // Navigate to the message before the reverted one (which will be the new last visible message) - const priorMessage = findLast(userMessages(), (x) => x.id < message.id) - setActiveMessage(priorMessage) - }, - }, - { - id: "session.redo", - title: language.t("command.session.redo"), - description: language.t("command.session.redo.description"), - category: language.t("command.category.session"), - slash: "redo", - disabled: !params.id || !info()?.revert?.messageID, - onSelect: async () => { - const sessionID = params.id - if (!sessionID) return - const revertMessageID = info()?.revert?.messageID - if (!revertMessageID) return - const nextMessage = userMessages().find((x) => x.id > revertMessageID) - if (!nextMessage) { - // Full unrevert - restore all messages and navigate to last - await sdk.client.session.unrevert({ sessionID }) - prompt.reset() - // Navigate to the last message (the one that was at the revert point) - const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID) - setActiveMessage(lastMsg) - return - } - // Partial redo - move forward to next message - await sdk.client.session.revert({ sessionID, messageID: nextMessage.id }) - // Navigate to the message before the new revert point - const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id) - setActiveMessage(priorMsg) - }, - }, - { - id: "session.compact", - title: language.t("command.session.compact"), - description: language.t("command.session.compact.description"), - category: language.t("command.category.session"), - slash: "compact", - disabled: !params.id || visibleUserMessages().length === 0, - onSelect: async () => { - const sessionID = params.id - if (!sessionID) return - const model = local.model.current() - if (!model) { - showToast({ - title: language.t("toast.model.none.title"), - description: language.t("toast.model.none.description"), - }) - return - } - await sdk.client.session.summarize({ - sessionID, - modelID: model.id, - providerID: model.provider.id, - }) - }, - }, - { - id: "session.fork", - title: language.t("command.session.fork"), - description: language.t("command.session.fork.description"), - category: language.t("command.category.session"), - slash: "fork", - disabled: !params.id || visibleUserMessages().length === 0, - onSelect: () => dialog.show(() => ), - }, - ...(sync.data.config.share !== "disabled" - ? [ - { - id: "session.share", - title: language.t("command.session.share"), - description: language.t("command.session.share.description"), - category: language.t("command.category.session"), - slash: "share", - disabled: !params.id || !!info()?.share?.url, - onSelect: async () => { - if (!params.id) return - await sdk.client.session - .share({ sessionID: params.id }) - .then((res) => { - navigator.clipboard.writeText(res.data!.share!.url).catch(() => - showToast({ - title: language.t("toast.session.share.copyFailed.title"), - variant: "error", - }), - ) - }) - .then(() => - showToast({ - title: language.t("toast.session.share.success.title"), - description: language.t("toast.session.share.success.description"), - variant: "success", - }), - ) - .catch(() => - showToast({ - title: language.t("toast.session.share.failed.title"), - description: language.t("toast.session.share.failed.description"), - variant: "error", - }), - ) - }, - }, - { - id: "session.unshare", - title: language.t("command.session.unshare"), - description: language.t("command.session.unshare.description"), - category: language.t("command.category.session"), - slash: "unshare", - disabled: !params.id || !info()?.share?.url, - onSelect: async () => { - if (!params.id) return - await sdk.client.session - .unshare({ sessionID: params.id }) - .then(() => - showToast({ - title: language.t("toast.session.unshare.success.title"), - description: language.t("toast.session.unshare.success.description"), - variant: "success", - }), - ) - .catch(() => - showToast({ - title: language.t("toast.session.unshare.failed.title"), - description: language.t("toast.session.unshare.failed.description"), - variant: "error", - }), - ) - }, - }, - ] - : []), - ]) - const handleKeyDown = (event: KeyboardEvent) => { const activeElement = document.activeElement as HTMLElement | undefined if (activeElement) { @@ -1407,19 +865,7 @@ export default function Page() { const activeId = terminal.active() if (!activeId) return setTimeout(() => { - const wrapper = document.getElementById(`terminal-wrapper-${activeId}`) - const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement - if (!element) return - - // Find and focus the ghostty textarea (the actual input element) - const textarea = element.querySelector("textarea") as HTMLTextAreaElement - if (textarea) { - textarea.focus() - return - } - // Fallback: focus container and dispatch pointer event - element.focus() - element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true })) + focusTerminalById(activeId) }, 0) } @@ -1457,6 +903,41 @@ export default function Page() { setFileTreeTab("all") } + useSessionCommands({ + command, + dialog, + file, + language, + local, + permission, + prompt, + sdk, + sync, + terminal, + layout, + params, + navigate, + tabs, + view, + info, + status, + userMessages, + visibleUserMessages, + activeMessage, + showAllFiles, + navigateMessageByOffset, + setExpanded: (id, fn) => setStore("expanded", id, fn), + setActiveMessage, + addSelectionToContext, + }) + + const openReviewFile = createOpenReviewFile({ + showAllFiles, + tabForPath: file.tab, + openTab: tabs().open, + loadFile: file.load, + }) + const changesOptions = ["session", "turn"] as const const changesOptionsList = [...changesOptions] @@ -1481,65 +962,72 @@ export default function Page() {
) + const reviewContent = (input: { + diffStyle: DiffStyle + onDiffStyleChange?: (style: DiffStyle) => void + classes?: SessionReviewTabProps["classes"] + loadingClass: string + emptyClass: string + }) => ( + + + setTree("reviewScroll", el)} + focusedFile={tree.activeDiff} + onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} + comments={comments.all()} + focusedComment={comments.focus()} + onFocusedCommentChange={comments.setFocus} + onViewFile={openReviewFile} + classes={input.classes} + /> + + + {language.t("session.review.loadingChanges")}
} + > + setTree("reviewScroll", el)} + focusedFile={tree.activeDiff} + onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} + comments={comments.all()} + focusedComment={comments.focus()} + onFocusedCommentChange={comments.setFocus} + onViewFile={openReviewFile} + classes={input.classes} + /> +
+ + +
+ +
{language.t("session.review.empty")}
+
+
+ + ) + const reviewPanel = () => (
- - - setTree("reviewScroll", el)} - focusedFile={tree.activeDiff} - onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} - comments={comments.all()} - focusedComment={comments.focus()} - onFocusedCommentChange={comments.setFocus} - onViewFile={(path) => { - showAllFiles() - const value = file.tab(path) - tabs().open(value) - file.load(path) - }} - /> - - - {language.t("session.review.loadingChanges")}
} - > - setTree("reviewScroll", el)} - focusedFile={tree.activeDiff} - onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} - comments={comments.all()} - focusedComment={comments.focus()} - onFocusedCommentChange={comments.setFocus} - onViewFile={(path) => { - showAllFiles() - const value = file.tab(path) - tabs().open(value) - file.load(path) - }} - /> - - - -
- -
{language.t("session.review.empty")}
-
-
- + {reviewContent({ + diffStyle: layout.review.diffStyle(), + onDiffStyleChange: layout.review.setDiffStyle, + loadingClass: "px-6 py-4 text-text-weak", + emptyClass: "h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6", + })}
) @@ -1656,6 +1144,12 @@ export default function Page() { return "empty" }) + const activeFileTab = createMemo(() => { + const active = activeTab() + if (!openedTabs().includes(active)) return + return active + }) + createEffect(() => { if (!layout.ready()) return if (tabs().active()) return @@ -1753,13 +1247,14 @@ export default function Page() { overflowAnchor: "dynamic", }) - const clearMessageHash = () => { - if (!window.location.hash) return - window.history.replaceState(null, "", window.location.href.replace(/#.*$/, "")) - } - let scrollStateFrame: number | undefined let scrollStateTarget: HTMLDivElement | undefined + const scrollSpy = createScrollSpy({ + onActive: (id) => { + if (id === store.messageId) return + setStore("messageId", id) + }, + }) const updateScrollState = (el: HTMLDivElement) => { const max = el.scrollHeight - el.clientHeight @@ -1807,16 +1302,11 @@ export default function Page() { ), ) - let scrollSpyFrame: number | undefined - let scrollSpyTarget: HTMLDivElement | undefined - createEffect( on( sessionKey, () => { - if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame) - scrollSpyFrame = undefined - scrollSpyTarget = undefined + scrollSpy.clear() }, { defer: true }, ), @@ -1827,6 +1317,7 @@ export default function Page() { const setScrollRef = (el: HTMLDivElement | undefined) => { scroller = el autoScroll.scrollRef(el) + scrollSpy.setContainer(el) if (el) scheduleScrollState(el) } @@ -1835,6 +1326,7 @@ export default function Page() { () => { const el = scroller if (el) scheduleScrollState(el) + scrollSpy.markDirty() }, ) @@ -1940,220 +1432,27 @@ export default function Page() { } if (el) scheduleScrollState(el) + scrollSpy.markDirty() }, ) - const updateHash = (id: string) => { - window.history.replaceState(null, "", `#${anchor(id)}`) - } - - createEffect( - on(sessionKey, (key) => { - if (!params.id) return - const messageID = layout.pendingMessage.consume(key) - if (!messageID) return - setUi("pendingMessage", messageID) - }), - ) - - const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { - const root = scroller - if (!root) return false - - const a = el.getBoundingClientRect() - const b = root.getBoundingClientRect() - const top = a.top - b.top + root.scrollTop - root.scrollTo({ top, behavior }) - return true - } - - const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { - setActiveMessage(message) - - const msgs = visibleUserMessages() - const index = msgs.findIndex((m) => m.id === message.id) - if (index !== -1 && index < store.turnStart) { - setStore("turnStart", index) - scheduleTurnBackfill() - - requestAnimationFrame(() => { - const el = document.getElementById(anchor(message.id)) - if (!el) { - requestAnimationFrame(() => { - const next = document.getElementById(anchor(message.id)) - if (!next) return - scrollToElement(next, behavior) - }) - return - } - scrollToElement(el, behavior) - }) - - updateHash(message.id) - return - } - - const el = document.getElementById(anchor(message.id)) - if (!el) { - updateHash(message.id) - requestAnimationFrame(() => { - const next = document.getElementById(anchor(message.id)) - if (!next) return - if (!scrollToElement(next, behavior)) return - }) - return - } - if (scrollToElement(el, behavior)) { - updateHash(message.id) - return - } - - requestAnimationFrame(() => { - const next = document.getElementById(anchor(message.id)) - if (!next) return - if (!scrollToElement(next, behavior)) return - }) - updateHash(message.id) - } - - const applyHash = (behavior: ScrollBehavior) => { - const hash = window.location.hash.slice(1) - if (!hash) { - autoScroll.forceScrollToBottom() - - const el = scroller - if (el) scheduleScrollState(el) - return - } - - const match = hash.match(/^message-(.+)$/) - if (match) { - autoScroll.pause() - const msg = visibleUserMessages().find((m) => m.id === match[1]) - if (msg) { - scrollToMessage(msg, behavior) - return - } - - // If we have a message hash but the message isn't loaded/rendered yet, - // don't fall back to "bottom". We'll retry once messages arrive. - return - } - - const target = document.getElementById(hash) - if (target) { - autoScroll.pause() - scrollToElement(target, behavior) - return - } - - autoScroll.forceScrollToBottom() - - const el = scroller - if (el) scheduleScrollState(el) - } - - const closestMessage = (node: Element | null): HTMLElement | null => { - if (!node) return null - const match = node.closest?.("[data-message-id]") as HTMLElement | null - if (match) return match - const root = node.getRootNode?.() - if (root instanceof ShadowRoot) return closestMessage(root.host) - return null - } - - const getActiveMessageId = (container: HTMLDivElement) => { - const rect = container.getBoundingClientRect() - if (!rect.width || !rect.height) return - - const x = Math.min(window.innerWidth - 1, Math.max(0, rect.left + rect.width / 2)) - const y = Math.min(window.innerHeight - 1, Math.max(0, rect.top + 100)) - - const hit = document.elementFromPoint(x, y) - const host = closestMessage(hit) - const id = host?.dataset.messageId - if (id) return id - - // Fallback: DOM query (handles edge hit-testing cases) - const cutoff = container.scrollTop + 100 - const nodes = container.querySelectorAll("[data-message-id]") - let last: string | undefined - - for (const node of nodes) { - const next = node.dataset.messageId - if (!next) continue - if (node.offsetTop > cutoff) break - last = next - } - - return last - } - - const scheduleScrollSpy = (container: HTMLDivElement) => { - scrollSpyTarget = container - if (scrollSpyFrame !== undefined) return - - scrollSpyFrame = requestAnimationFrame(() => { - scrollSpyFrame = undefined - - const target = scrollSpyTarget - scrollSpyTarget = undefined - if (!target) return - - const id = getActiveMessageId(target) - if (!id) return - if (id === store.messageId) return - - setStore("messageId", id) - }) - } - - createEffect(() => { - const sessionID = params.id - const ready = messagesReady() - if (!sessionID || !ready) return - - requestAnimationFrame(() => { - applyHash("auto") - }) - }) - - // Retry message navigation once the target message is actually loaded. - createEffect(() => { - const sessionID = params.id - const ready = messagesReady() - if (!sessionID || !ready) return - - // dependencies - visibleUserMessages().length - store.turnStart - - const targetId = - ui.pendingMessage ?? - (() => { - const hash = window.location.hash.slice(1) - const match = hash.match(/^message-(.+)$/) - if (!match) return undefined - return match[1] - })() - if (!targetId) return - if (store.messageId === targetId) return - - const msg = visibleUserMessages().find((m) => m.id === targetId) - if (!msg) return - if (ui.pendingMessage === targetId) setUi("pendingMessage", undefined) - autoScroll.pause() - requestAnimationFrame(() => scrollToMessage(msg, "auto")) - }) - - createEffect(() => { - const sessionID = params.id - const ready = messagesReady() - if (!sessionID || !ready) return - - const handler = () => requestAnimationFrame(() => applyHash("auto")) - window.addEventListener("hashchange", handler) - onCleanup(() => window.removeEventListener("hashchange", handler)) + const { clearMessageHash, scrollToMessage } = useSessionHashScroll({ + sessionKey, + sessionID: () => params.id, + messagesReady, + visibleUserMessages, + turnStart: () => store.turnStart, + currentMessageId: () => store.messageId, + pendingMessage: () => ui.pendingMessage, + setPendingMessage: (value) => setUi("pendingMessage", value), + setActiveMessage, + setTurnStart: (value) => setStore("turnStart", value), + scheduleTurnBackfill, + autoScroll, + scroller: () => scroller, + anchor, + scheduleScrollState, + consumePendingMessage: layout.pendingMessage.consume, }) createEffect(() => { @@ -2181,20 +1480,17 @@ export default function Page() { if (!terminal.ready()) return language.locale() - const label = (pty: LocalPTY) => { - const title = pty.title - const number = pty.titleNumber - const match = title.match(/^Terminal (\d+)$/) - const parsed = match ? Number(match[1]) : undefined - const isDefaultTitle = Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number - - if (title && !isDefaultTitle) return title - if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number }) - if (title) return title - return language.t("terminal.title") - } - - touch(handoff.terminal, params.dir!, terminal.all().map(label)) + touch( + handoff.terminal, + params.dir!, + terminal.all().map((pty) => + terminalTabLabel({ + title: pty.title, + titleNumber: pty.titleNumber, + t: language.t as (key: string, vars?: Record) => string, + }), + ), + ) }) createEffect(() => { @@ -2215,7 +1511,7 @@ export default function Page() { onCleanup(() => { cancelTurnBackfill() document.removeEventListener("keydown", handleKeyDown) - if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame) + scrollSpy.destroy() if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame) }) @@ -2223,34 +1519,14 @@ export default function Page() {
- {/* Mobile tab bar */} - - - - setStore("mobileTab", "session")} - > - {language.t("session.tab.session")} - - setStore("mobileTab", "changes")} - > - - - {language.t("session.review.filesChanged", { count: reviewCount() })} - - {language.t("session.review.change.other")} - - - - - + setStore("mobileTab", "session")} + onChanges={() => setStore("mobileTab", "changes")} + t={language.t as (key: string, vars?: Record) => string} + /> {/* Session panel */}
- - - - addCommentToContext({ ...comment, origin: "review" })} - comments={comments.all()} - focusedComment={comments.focus()} - onFocusedCommentChange={comments.setFocus} - onViewFile={(path) => { - showAllFiles() - 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", - }} - /> - - - - {language.t("session.review.loadingChanges")} -
- } - > - addCommentToContext({ ...comment, origin: "review" })} - comments={comments.all()} - focusedComment={comments.focus()} - onFocusedCommentChange={comments.setFocus} - onViewFile={(path) => { - showAllFiles() - 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", - }} - /> - - - -
- -
- {language.t("session.review.empty")} -
-
-
- -
- } - > -
-
- -
-
{ - const root = e.currentTarget - const target = e.target instanceof Element ? e.target : undefined - const nested = target?.closest("[data-scrollable]") - if (!nested || nested === root) { - markScrollGesture(root) - return - } + { + titleRef = el + }} + titleState={title} + onTitleDraft={(value) => setTitle("draft", value)} + onTitleMenuOpen={(open) => setTitle("menuOpen", open)} + onTitlePendingRename={(value) => setTitle("pendingRename", value)} + onNavigateParent={() => { + navigate(`/${params.dir}/session/${info()?.parentID}`) + }} + sessionID={params.id!} + onArchiveSession={(sessionID) => void archiveSession(sessionID)} + onDeleteSession={(sessionID) => dialog.show(() => )} + t={language.t as (key: string, vars?: Record) => string} + setContentRef={(el) => { + content = el + autoScroll.contentRef(el) - if (!(nested instanceof HTMLElement)) { - markScrollGesture(root) - return - } - - const max = nested.scrollHeight - nested.clientHeight - if (max <= 1) { - markScrollGesture(root) - return - } - - const delta = - e.deltaMode === 1 - ? e.deltaY * 40 - : e.deltaMode === 2 - ? e.deltaY * root.clientHeight - : e.deltaY - if (!delta) return - - if (delta < 0) { - if (nested.scrollTop + delta <= 0) markScrollGesture(root) - return - } - - const remaining = max - nested.scrollTop - if (delta > remaining) markScrollGesture(root) - }} - onTouchStart={(e) => { - touchGesture = e.touches[0]?.clientY - }} - onTouchMove={(e) => { - const next = e.touches[0]?.clientY - const prev = touchGesture - touchGesture = next - if (next === undefined || prev === undefined) return - - const delta = prev - next - if (!delta) return - - const root = e.currentTarget - const target = e.target instanceof Element ? e.target : undefined - const nested = target?.closest("[data-scrollable]") - if (!nested || nested === root) { - markScrollGesture(root) - return - } - - if (!(nested instanceof HTMLElement)) { - markScrollGesture(root) - return - } - - const max = nested.scrollHeight - nested.clientHeight - if (max <= 1) { - markScrollGesture(root) - return - } - - if (delta < 0) { - if (nested.scrollTop + delta <= 0) markScrollGesture(root) - return - } - - const remaining = max - nested.scrollTop - if (delta > remaining) markScrollGesture(root) - }} - onTouchEnd={() => { - touchGesture = undefined - }} - onTouchCancel={() => { - touchGesture = undefined - }} - onPointerDown={(e) => { - if (e.target !== e.currentTarget) return - markScrollGesture(e.currentTarget) - }} - onScroll={(e) => { - scheduleScrollState(e.currentTarget) - if (!hasScrollGesture()) return - autoScroll.handleScroll() - markScrollGesture(e.currentTarget) - if (isDesktop()) scheduleScrollSpy(e.currentTarget) - }} - onClick={autoScroll.handleInteraction} - class="relative min-w-0 w-full h-full overflow-y-auto session-scroller" - style={{ "--session-title-height": info()?.title || info()?.parentID ? "40px" : "0px" }} - > - -
-
-
- - { - navigate(`/${params.dir}/session/${info()?.parentID}`) - }} - aria-label={language.t("common.goBack")} - /> - - - - {info()?.title} - - } - > - { - titleRef = el - }} - value={title.draft} - disabled={title.saving} - class="text-16-medium text-text-strong grow-1 min-w-0" - onInput={(event) => setTitle("draft", event.currentTarget.value)} - onKeyDown={(event) => { - event.stopPropagation() - if (event.key === "Enter") { - event.preventDefault() - void saveTitleEditor() - return - } - if (event.key === "Escape") { - event.preventDefault() - closeTitleEditor() - } - }} - onBlur={() => closeTitleEditor()} - /> - - -
- - {(id) => ( -
- setTitle("menuOpen", open)} - > - - - - - { - if (!title.pendingRename) return - event.preventDefault() - setTitle("pendingRename", false) - openTitleEditor() - }} - > - { - setTitle({ pendingRename: true, menuOpen: false }) - }} - > - - {language.t("common.rename")} - - - void archiveSession(id())}> - - {language.t("common.archive")} - - - - dialog.show(() => )} - > - - {language.t("common.delete")} - - - - - -
- )} -
-
-
-
- -
{ - content = el - autoScroll.contentRef(el) - - const root = scroller - if (root) scheduleScrollState(root) - }} - role="log" - class="flex flex-col gap-12 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]" - classList={{ - "w-full": true, - "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": - centered(), - "mt-0.5": centered(), - "mt-0": !centered(), - }} - > - 0}> -
- -
-
- -
- -
-
- - {(message) => { - if (import.meta.env.DEV) { - onMount(() => { - const id = params.id - if (!id) return - navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" }) - }) - } - - return ( -
- - setStore("expanded", message.id, (open: boolean | undefined) => !open) - } - classes={{ - root: "min-w-0 w-full relative", - content: "flex flex-col justify-between !overflow-visible", - container: "w-full px-4 md:px-6", - }} - /> -
- ) - }} -
-
-
-
- + const root = scroller + if (root) scheduleScrollState(root) + }} + turnStart={store.turnStart} + onRenderEarlier={() => setStore("turnStart", 0)} + historyMore={historyMore()} + historyLoading={historyLoading()} + onLoadEarlier={() => { + const id = params.id + if (!id) return + setStore("turnStart", 0) + sync.session.history.loadMore(id) + }} + renderedUserMessages={renderedUserMessages()} + anchor={anchor} + onRegisterMessage={scrollSpy.register} + onUnregisterMessage={scrollSpy.unregister} + onFirstTurnMount={() => { + const id = params.id + if (!id) return + navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" }) + }} + lastUserMessageID={lastUserMessage()?.id} + expanded={store.expanded} + onToggleExpanded={(id) => setStore("expanded", id, (open: boolean | undefined) => !open)} + /> @@ -2689,115 +1641,27 @@ export default function Page() {
- {/* Prompt input */} -
(promptDock = el)} - class="absolute inset-x-0 bottom-0 pt-12 pb-4 flex flex-col justify-center items-center z-50 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none" - > -
- - {(req) => { - const count = req.questions.length - const subtitle = - count === 0 - ? "" - : `${count} ${language.t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}` - return ( -
- - -
- ) - }} -
- - - {(perm) => ( -
- - 0}> -
- - {(pattern) => {pattern}} - -
-
- -
- {language.t("settings.permissions.tool.doom_loop.description")} -
-
-
-
-
- - - -
-
-
- )} -
- - - - {handoff.session.get(sessionKey())?.prompt || language.t("prompt.loading")} -
- } - > - { - inputRef = el - }} - newSessionWorktree={newSessionWorktree()} - onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} - onSubmit={() => { - comments.clear() - resumeScroll() - }} - /> - - -
-
+ ) => string} + responding={ui.responding} + onDecide={decide} + inputRef={(el) => { + inputRef = el + }} + newSessionWorktree={newSessionWorktree()} + onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} + onSubmit={() => { + comments.clear() + resumeScroll() + }} + setPromptDockRef={(el) => (promptDock = el)} + />
- {/* Desktop side panel - hidden on mobile */} - -
- - - + unknown[]} + visibleUserMessages={visibleUserMessages as () => unknown[]} + view={view} + info={info as () => unknown} + handoffFiles={() => handoff.session.get(sessionKey())?.files} + codeComponent={codeComponent} + addCommentToContext={addCommentToContext} + activeDraggable={() => store.activeDraggable} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + onDragOver={handleDragOver} + fileTreeTab={fileTreeTab} + setFileTreeTabValue={setFileTreeTabValue} + diffsReady={diffsReady()} + diffFiles={diffFiles()} + kinds={kinds()} + activeDiff={tree.activeDiff} + focusReviewDiff={focusReviewDiff} + />
- -
- - -
- - {(title) => ( -
- {title} -
- )} -
-
-
- {language.t("common.loading")} - {language.t("common.loading.ellipsis")} -
-
-
- {language.t("terminal.loading")} -
-
- } - > - - - -
- { - // Only switch tabs if not in the middle of starting edit mode - terminal.open(id) - }} - class="!h-auto !flex-none" - > - - t.id)}> - - {(pty) => ( - { - view().terminal.close() - setUi("autoCreated", false) - }} - /> - )} - - -
- - - -
-
-
-
- - {(pty) => ( -
- - terminal.clone(pty.id)} - /> - -
- )} -
-
-
- - - {(draggedId) => { - const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId())) - return ( - - {(t) => ( -
- {(() => { - const title = t().title - const number = t().titleNumber - const match = title.match(/^Terminal (\d+)$/) - const parsed = match ? Number(match[1]) : undefined - const isDefaultTitle = - Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number - - if (title && !isDefaultTitle) return title - if (Number.isFinite(number) && number > 0) - return language.t("terminal.title.numbered", { number }) - if (title) return title - return language.t("terminal.title") - })()} -
- )} -
- ) - }} -
-
-
-
-
-
+ handoff.terminal.get(params.dir!) ?? []} + activeTerminalDraggable={() => store.activeTerminalDraggable} + handleTerminalDragStart={handleTerminalDragStart} + handleTerminalDragOver={handleTerminalDragOver} + handleTerminalDragEnd={handleTerminalDragEnd} + onCloseTab={() => setUi("autoCreated", false)} + />
) } diff --git a/packages/app/src/pages/session/file-tab-scroll.test.ts b/packages/app/src/pages/session/file-tab-scroll.test.ts new file mode 100644 index 0000000000..89e0dcc8fd --- /dev/null +++ b/packages/app/src/pages/session/file-tab-scroll.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test" +import { nextTabListScrollLeft } from "./file-tab-scroll" + +describe("nextTabListScrollLeft", () => { + test("does not scroll when width shrinks", () => { + const left = nextTabListScrollLeft({ + prevScrollWidth: 500, + scrollWidth: 420, + clientWidth: 300, + prevContextOpen: false, + contextOpen: false, + }) + + expect(left).toBeUndefined() + }) + + test("scrolls to start when context tab opens", () => { + const left = nextTabListScrollLeft({ + prevScrollWidth: 400, + scrollWidth: 500, + clientWidth: 320, + prevContextOpen: false, + contextOpen: true, + }) + + expect(left).toBe(0) + }) + + test("scrolls to right edge for new file tabs", () => { + const left = nextTabListScrollLeft({ + prevScrollWidth: 500, + scrollWidth: 780, + clientWidth: 300, + prevContextOpen: true, + contextOpen: true, + }) + + expect(left).toBe(480) + }) +}) diff --git a/packages/app/src/pages/session/file-tab-scroll.ts b/packages/app/src/pages/session/file-tab-scroll.ts new file mode 100644 index 0000000000..b69188d405 --- /dev/null +++ b/packages/app/src/pages/session/file-tab-scroll.ts @@ -0,0 +1,67 @@ +type Input = { + prevScrollWidth: number + scrollWidth: number + clientWidth: number + prevContextOpen: boolean + contextOpen: boolean +} + +export const nextTabListScrollLeft = (input: Input) => { + if (input.scrollWidth <= input.prevScrollWidth) return + if (!input.prevContextOpen && input.contextOpen) return 0 + if (input.scrollWidth <= input.clientWidth) return + return input.scrollWidth - input.clientWidth +} + +export const createFileTabListSync = (input: { el: HTMLDivElement; contextOpen: () => boolean }) => { + let frame: number | undefined + let prevScrollWidth = input.el.scrollWidth + let prevContextOpen = input.contextOpen() + + const update = () => { + const scrollWidth = input.el.scrollWidth + const clientWidth = input.el.clientWidth + const contextOpen = input.contextOpen() + const left = nextTabListScrollLeft({ + prevScrollWidth, + scrollWidth, + clientWidth, + prevContextOpen, + contextOpen, + }) + + if (left !== undefined) { + input.el.scrollTo({ + left, + behavior: "smooth", + }) + } + + prevScrollWidth = scrollWidth + prevContextOpen = contextOpen + } + + const schedule = () => { + if (frame !== undefined) cancelAnimationFrame(frame) + frame = requestAnimationFrame(() => { + frame = undefined + update() + }) + } + + const onWheel = (e: WheelEvent) => { + if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return + input.el.scrollLeft += e.deltaY > 0 ? 50 : -50 + e.preventDefault() + } + + input.el.addEventListener("wheel", onWheel, { passive: false }) + const observer = new MutationObserver(schedule) + observer.observe(input.el, { childList: true }) + + return () => { + input.el.removeEventListener("wheel", onWheel) + observer.disconnect() + if (frame !== undefined) cancelAnimationFrame(frame) + } +} diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx new file mode 100644 index 0000000000..0c8281a66d --- /dev/null +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -0,0 +1,516 @@ +import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js" +import { createStore } from "solid-js/store" +import { Dynamic } from "solid-js/web" +import { checksum } from "@opencode-ai/util/encode" +import { decode64 } from "@/utils/base64" +import { showToast } from "@opencode-ai/ui/toast" +import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment" +import { Mark } from "@opencode-ai/ui/logo" +import { Tabs } from "@opencode-ai/ui/tabs" +import { useLayout } from "@/context/layout" +import { useFile, type SelectedLineRange } from "@/context/file" +import { useComments } from "@/context/comments" +import { useLanguage } from "@/context/language" + +export function FileTabContent(props: { + tab: string + activeTab: () => string + tabs: () => ReturnType["tabs"]> + view: () => ReturnType["view"]> + handoffFiles: () => Record | undefined + file: ReturnType + comments: ReturnType + language: ReturnType + codeComponent: NonNullable + addCommentToContext: (input: { + file: string + selection: SelectedLineRange + comment: string + preview?: string + origin?: "review" | "file" + }) => void +}) { + let scroll: HTMLDivElement | undefined + let scrollFrame: number | undefined + let pending: { x: number; y: number } | undefined + let codeScroll: HTMLElement[] = [] + + const path = createMemo(() => props.file.pathFromTab(props.tab)) + const state = createMemo(() => { + const p = path() + if (!p) return + return props.file.get(p) + }) + const contents = createMemo(() => state()?.content?.content ?? "") + const cacheKey = createMemo(() => checksum(contents())) + const isImage = createMemo(() => { + const c = state()?.content + return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml" + }) + const isSvg = createMemo(() => { + const c = state()?.content + return c?.mimeType === "image/svg+xml" + }) + const isBinary = createMemo(() => state()?.content?.type === "binary") + const svgContent = createMemo(() => { + if (!isSvg()) return + const c = state()?.content + if (!c) return + if (c.encoding !== "base64") return c.content + return decode64(c.content) + }) + + const svgDecodeFailed = createMemo(() => { + if (!isSvg()) return false + const c = state()?.content + if (!c) return false + if (c.encoding !== "base64") return false + return svgContent() === undefined + }) + + const svgToast = { shown: false } + createEffect(() => { + if (!svgDecodeFailed()) return + if (svgToast.shown) return + svgToast.shown = true + showToast({ + variant: "error", + title: props.language.t("toast.file.loadFailed.title"), + description: "Invalid base64 content.", + }) + }) + const svgPreviewUrl = createMemo(() => { + if (!isSvg()) return + const c = state()?.content + if (!c) return + if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}` + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}` + }) + const imageDataUrl = createMemo(() => { + if (!isImage()) return + const c = state()?.content + return `data:${c?.mimeType};base64,${c?.content}` + }) + const selectedLines = createMemo(() => { + const p = path() + if (!p) return null + if (props.file.ready()) return props.file.selectedLines(p) ?? null + return props.handoffFiles()?.[p] ?? null + }) + + let wrap: HTMLDivElement | undefined + + const fileComments = createMemo(() => { + const p = path() + if (!p) return [] + return props.comments.list(p) + }) + + const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection)) + + const [note, setNote] = createStore({ + openedComment: null as string | null, + commenting: null as SelectedLineRange | null, + draft: "", + positions: {} as Record, + draftTop: undefined as number | undefined, + }) + + const openedComment = () => note.openedComment + const setOpenedComment = ( + value: typeof note.openedComment | ((value: typeof note.openedComment) => typeof note.openedComment), + ) => setNote("openedComment", value) + + const commenting = () => note.commenting + const setCommenting = (value: typeof note.commenting | ((value: typeof note.commenting) => typeof note.commenting)) => + setNote("commenting", value) + + const draft = () => note.draft + const setDraft = (value: typeof note.draft | ((value: typeof note.draft) => typeof note.draft)) => + setNote("draft", value) + + const positions = () => note.positions + const setPositions = (value: typeof note.positions | ((value: typeof note.positions) => typeof note.positions)) => + setNote("positions", value) + + const draftTop = () => note.draftTop + const setDraftTop = (value: typeof note.draftTop | ((value: typeof note.draftTop) => typeof note.draftTop)) => + setNote("draftTop", value) + + const commentLabel = (range: SelectedLineRange) => { + const start = Math.min(range.start, range.end) + const end = Math.max(range.start, range.end) + if (start === end) return `line ${start}` + return `lines ${start}-${end}` + } + + const getRoot = () => { + const el = wrap + if (!el) return + + const host = el.querySelector("diffs-container") + if (!(host instanceof HTMLElement)) return + + const root = host.shadowRoot + if (!root) return + + return root + } + + const findMarker = (root: ShadowRoot, range: SelectedLineRange) => { + const line = Math.max(range.start, range.end) + const node = root.querySelector(`[data-line="${line}"]`) + if (!(node instanceof HTMLElement)) return + return node + } + + const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => { + const wrapperRect = wrapper.getBoundingClientRect() + const rect = marker.getBoundingClientRect() + return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2) + } + + const updateComments = () => { + const el = wrap + const root = getRoot() + if (!el || !root) { + setPositions({}) + setDraftTop(undefined) + return + } + + const next: Record = {} + for (const comment of fileComments()) { + const marker = findMarker(root, comment.selection) + if (!marker) continue + next[comment.id] = markerTop(el, marker) + } + + setPositions(next) + + const range = commenting() + if (!range) { + setDraftTop(undefined) + return + } + + const marker = findMarker(root, range) + if (!marker) { + setDraftTop(undefined) + return + } + + setDraftTop(markerTop(el, marker)) + } + + const scheduleComments = () => { + requestAnimationFrame(updateComments) + } + + createEffect(() => { + fileComments() + scheduleComments() + }) + + createEffect(() => { + const range = commenting() + scheduleComments() + if (!range) return + setDraft("") + }) + + createEffect(() => { + const focus = props.comments.focus() + const p = path() + if (!focus || !p) return + if (focus.file !== p) return + if (props.activeTab() !== props.tab) return + + const target = fileComments().find((comment) => comment.id === focus.id) + if (!target) return + + setOpenedComment(target.id) + setCommenting(null) + props.file.setSelectedLines(p, target.selection) + requestAnimationFrame(() => props.comments.clearFocus()) + }) + + const getCodeScroll = () => { + const el = scroll + if (!el) return [] + + const host = el.querySelector("diffs-container") + if (!(host instanceof HTMLElement)) return [] + + const root = host.shadowRoot + if (!root) return [] + + return Array.from(root.querySelectorAll("[data-code]")).filter( + (node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0, + ) + } + + const queueScrollUpdate = (next: { x: number; y: number }) => { + pending = next + if (scrollFrame !== undefined) return + + scrollFrame = requestAnimationFrame(() => { + scrollFrame = undefined + + const out = pending + pending = undefined + if (!out) return + + props.view().setScroll(props.tab, out) + }) + } + + const handleCodeScroll = (event: Event) => { + const el = scroll + if (!el) return + + const target = event.currentTarget + if (!(target instanceof HTMLElement)) return + + queueScrollUpdate({ + x: target.scrollLeft, + y: el.scrollTop, + }) + } + + const syncCodeScroll = () => { + const next = getCodeScroll() + if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return + + for (const item of codeScroll) { + item.removeEventListener("scroll", handleCodeScroll) + } + + codeScroll = next + + for (const item of codeScroll) { + item.addEventListener("scroll", handleCodeScroll) + } + } + + const restoreScroll = () => { + const el = scroll + if (!el) return + + const s = props.view()?.scroll(props.tab) + if (!s) return + + syncCodeScroll() + + if (codeScroll.length > 0) { + for (const item of codeScroll) { + if (item.scrollLeft !== s.x) item.scrollLeft = s.x + } + } + + if (el.scrollTop !== s.y) el.scrollTop = s.y + if (codeScroll.length > 0) return + if (el.scrollLeft !== s.x) el.scrollLeft = s.x + } + + const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + if (codeScroll.length === 0) syncCodeScroll() + + queueScrollUpdate({ + x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft, + y: event.currentTarget.scrollTop, + }) + } + + createEffect( + on( + () => state()?.loaded, + (loaded) => { + if (!loaded) return + requestAnimationFrame(restoreScroll) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => props.file.ready(), + (ready) => { + if (!ready) return + requestAnimationFrame(restoreScroll) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => props.tabs().active() === props.tab, + (active) => { + if (!active) return + if (!state()?.loaded) return + requestAnimationFrame(restoreScroll) + }, + ), + ) + + onCleanup(() => { + for (const item of codeScroll) { + item.removeEventListener("scroll", handleCodeScroll) + } + + if (scrollFrame === undefined) return + cancelAnimationFrame(scrollFrame) + }) + + const renderCode = (source: string, wrapperClass: string) => ( +
{ + wrap = el + scheduleComments() + }} + class={`relative overflow-hidden ${wrapperClass}`} + > + { + requestAnimationFrame(restoreScroll) + requestAnimationFrame(scheduleComments) + }} + onLineSelected={(range: SelectedLineRange | null) => { + const p = path() + if (!p) return + props.file.setSelectedLines(p, range) + if (!range) setCommenting(null) + }} + onLineSelectionEnd={(range: SelectedLineRange | null) => { + if (!range) { + setCommenting(null) + return + } + + setOpenedComment(null) + setCommenting(range) + }} + overflow="scroll" + class="select-text" + /> + + {(comment) => ( + { + const p = path() + if (!p) return + props.file.setSelectedLines(p, comment.selection) + }} + onClick={() => { + const p = path() + if (!p) return + setCommenting(null) + setOpenedComment((current) => (current === comment.id ? null : comment.id)) + props.file.setSelectedLines(p, comment.selection) + }} + /> + )} + + + {(range) => ( + + setDraft(value)} + onCancel={() => setCommenting(null)} + onSubmit={(value) => { + const p = path() + if (!p) return + props.addCommentToContext({ + file: p, + selection: range(), + comment: value, + origin: "file", + }) + setCommenting(null) + }} + onPopoverFocusOut={(e: FocusEvent) => { + const current = e.currentTarget as HTMLDivElement + const target = e.relatedTarget + if (target instanceof Node && current.contains(target)) return + + setTimeout(() => { + if (!document.activeElement || !current.contains(document.activeElement)) { + setCommenting(null) + } + }, 0) + }} + /> + + )} + +
+ ) + + return ( + { + scroll = el + restoreScroll() + }} + onScroll={handleScroll} + > + + +
+ {path()} requestAnimationFrame(restoreScroll)} + /> +
+
+ +
+ {renderCode(svgContent() ?? "", "")} + +
+ {path()} +
+
+
+
+ +
+ +
+
{path()?.split("/").pop()}
+
{props.language.t("session.files.binaryContent")}
+
+
+
+ {renderCode(contents(), "pb-40")} + +
{props.language.t("common.loading")}...
+
+ {(err) =>
{err()}
}
+
+
+ ) +} diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts new file mode 100644 index 0000000000..0afc7eb6a5 --- /dev/null +++ b/packages/app/src/pages/session/helpers.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { combineCommandSections, createOpenReviewFile, focusTerminalById } from "./helpers" + +describe("createOpenReviewFile", () => { + test("opens and loads selected review file", () => { + const calls: string[] = [] + const openReviewFile = createOpenReviewFile({ + showAllFiles: () => calls.push("show"), + tabForPath: (path) => { + calls.push(`tab:${path}`) + return `file://${path}` + }, + openTab: (tab) => calls.push(`open:${tab}`), + loadFile: (path) => calls.push(`load:${path}`), + }) + + openReviewFile("src/a.ts") + + expect(calls).toEqual(["show", "tab:src/a.ts", "open:file://src/a.ts", "load:src/a.ts"]) + }) +}) + +describe("focusTerminalById", () => { + test("focuses textarea when present", () => { + document.body.innerHTML = `
` + + const focused = focusTerminalById("one") + + expect(focused).toBe(true) + expect(document.activeElement?.tagName).toBe("TEXTAREA") + }) + + test("falls back to terminal element focus", () => { + document.body.innerHTML = `
` + const terminal = document.querySelector('[data-component="terminal"]') as HTMLElement + let pointerDown = false + terminal.addEventListener("pointerdown", () => { + pointerDown = true + }) + + const focused = focusTerminalById("two") + + expect(focused).toBe(true) + expect(document.activeElement).toBe(terminal) + expect(pointerDown).toBe(true) + }) +}) + +describe("combineCommandSections", () => { + test("keeps section order stable", () => { + const result = combineCommandSections([ + [{ id: "a", title: "A" }], + [ + { id: "b", title: "B" }, + { id: "c", title: "C" }, + ], + ]) + + expect(result.map((item) => item.id)).toEqual(["a", "b", "c"]) + }) +}) diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts new file mode 100644 index 0000000000..d9ce90793f --- /dev/null +++ b/packages/app/src/pages/session/helpers.ts @@ -0,0 +1,38 @@ +import type { CommandOption } from "@/context/command" + +export const focusTerminalById = (id: string) => { + const wrapper = document.getElementById(`terminal-wrapper-${id}`) + const terminal = wrapper?.querySelector('[data-component="terminal"]') + if (!(terminal instanceof HTMLElement)) return false + + const textarea = terminal.querySelector("textarea") + if (textarea instanceof HTMLTextAreaElement) { + textarea.focus() + return true + } + + terminal.focus() + terminal.dispatchEvent( + typeof PointerEvent === "function" + ? new PointerEvent("pointerdown", { bubbles: true, cancelable: true }) + : new MouseEvent("pointerdown", { bubbles: true, cancelable: true }), + ) + return true +} + +export const createOpenReviewFile = (input: { + showAllFiles: () => void + tabForPath: (path: string) => string + openTab: (tab: string) => void + loadFile: (path: string) => void +}) => { + return (path: string) => { + input.showAllFiles() + input.openTab(input.tabForPath(path)) + input.loadFile(path) + } +} + +export const combineCommandSections = (sections: readonly (readonly CommandOption[])[]) => { + return sections.flatMap((section) => section) +} diff --git a/packages/app/src/pages/session/message-gesture.test.ts b/packages/app/src/pages/session/message-gesture.test.ts new file mode 100644 index 0000000000..b2af4bb834 --- /dev/null +++ b/packages/app/src/pages/session/message-gesture.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test" +import { normalizeWheelDelta, shouldMarkBoundaryGesture } from "./message-gesture" + +describe("normalizeWheelDelta", () => { + test("converts line mode to px", () => { + expect(normalizeWheelDelta({ deltaY: 3, deltaMode: 1, rootHeight: 500 })).toBe(120) + }) + + test("converts page mode to container height", () => { + expect(normalizeWheelDelta({ deltaY: -1, deltaMode: 2, rootHeight: 600 })).toBe(-600) + }) + + test("keeps pixel mode unchanged", () => { + expect(normalizeWheelDelta({ deltaY: 16, deltaMode: 0, rootHeight: 600 })).toBe(16) + }) +}) + +describe("shouldMarkBoundaryGesture", () => { + test("marks when nested scroller cannot scroll", () => { + expect( + shouldMarkBoundaryGesture({ + delta: 20, + scrollTop: 0, + scrollHeight: 300, + clientHeight: 300, + }), + ).toBe(true) + }) + + test("marks when scrolling beyond top boundary", () => { + expect( + shouldMarkBoundaryGesture({ + delta: -40, + scrollTop: 10, + scrollHeight: 1000, + clientHeight: 400, + }), + ).toBe(true) + }) + + test("marks when scrolling beyond bottom boundary", () => { + expect( + shouldMarkBoundaryGesture({ + delta: 50, + scrollTop: 580, + scrollHeight: 1000, + clientHeight: 400, + }), + ).toBe(true) + }) + + test("does not mark when nested scroller can consume movement", () => { + expect( + shouldMarkBoundaryGesture({ + delta: 20, + scrollTop: 200, + scrollHeight: 1000, + clientHeight: 400, + }), + ).toBe(false) + }) +}) diff --git a/packages/app/src/pages/session/message-gesture.ts b/packages/app/src/pages/session/message-gesture.ts new file mode 100644 index 0000000000..731cb1bdeb --- /dev/null +++ b/packages/app/src/pages/session/message-gesture.ts @@ -0,0 +1,21 @@ +export const normalizeWheelDelta = (input: { deltaY: number; deltaMode: number; rootHeight: number }) => { + if (input.deltaMode === 1) return input.deltaY * 40 + if (input.deltaMode === 2) return input.deltaY * input.rootHeight + return input.deltaY +} + +export const shouldMarkBoundaryGesture = (input: { + delta: number + scrollTop: number + scrollHeight: number + clientHeight: number +}) => { + const max = input.scrollHeight - input.clientHeight + if (max <= 1) return true + if (!input.delta) return false + + if (input.delta < 0) return input.scrollTop + input.delta <= 0 + + const remaining = max - input.scrollTop + return input.delta > remaining +} diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx new file mode 100644 index 0000000000..f536c7061f --- /dev/null +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -0,0 +1,348 @@ +import { For, onCleanup, onMount, Show, type JSX } from "solid-js" +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { InlineInput } from "@opencode-ai/ui/inline-input" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { SessionTurn } from "@opencode-ai/ui/session-turn" +import type { UserMessage } from "@opencode-ai/sdk/v2" +import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" + +export function MessageTimeline(props: { + mobileChanges: boolean + mobileFallback: JSX.Element + scroll: { overflow: boolean; bottom: boolean } + onResumeScroll: () => void + setScrollRef: (el: HTMLDivElement | undefined) => void + onScheduleScrollState: (el: HTMLDivElement) => void + onAutoScrollHandleScroll: () => void + onMarkScrollGesture: (target?: EventTarget | null) => void + hasScrollGesture: () => boolean + isDesktop: boolean + onScrollSpyScroll: () => void + onAutoScrollInteraction: (event: MouseEvent) => void + showHeader: boolean + centered: boolean + title?: string + parentID?: string + openTitleEditor: () => void + closeTitleEditor: () => void + saveTitleEditor: () => void | Promise + titleRef: (el: HTMLInputElement) => void + titleState: { + draft: string + editing: boolean + saving: boolean + menuOpen: boolean + pendingRename: boolean + } + onTitleDraft: (value: string) => void + onTitleMenuOpen: (open: boolean) => void + onTitlePendingRename: (value: boolean) => void + onNavigateParent: () => void + sessionID: string + onArchiveSession: (sessionID: string) => void + onDeleteSession: (sessionID: string) => void + t: (key: string, vars?: Record) => string + setContentRef: (el: HTMLDivElement) => void + turnStart: number + onRenderEarlier: () => void + historyMore: boolean + historyLoading: boolean + onLoadEarlier: () => void + renderedUserMessages: UserMessage[] + anchor: (id: string) => string + onRegisterMessage: (el: HTMLDivElement, id: string) => void + onUnregisterMessage: (id: string) => void + onFirstTurnMount?: () => void + lastUserMessageID?: string + expanded: Record + onToggleExpanded: (id: string) => void +}) { + let touchGesture: number | undefined + + return ( + {props.mobileFallback}
} + > +
+
+ +
+
{ + const root = e.currentTarget + const target = e.target instanceof Element ? e.target : undefined + const nested = target?.closest("[data-scrollable]") + if (!nested || nested === root) { + props.onMarkScrollGesture(root) + return + } + + if (!(nested instanceof HTMLElement)) { + props.onMarkScrollGesture(root) + return + } + + const delta = normalizeWheelDelta({ + deltaY: e.deltaY, + deltaMode: e.deltaMode, + rootHeight: root.clientHeight, + }) + if (!delta) return + + if ( + shouldMarkBoundaryGesture({ + delta, + scrollTop: nested.scrollTop, + scrollHeight: nested.scrollHeight, + clientHeight: nested.clientHeight, + }) + ) { + props.onMarkScrollGesture(root) + } + }} + onTouchStart={(e) => { + touchGesture = e.touches[0]?.clientY + }} + onTouchMove={(e) => { + const next = e.touches[0]?.clientY + const prev = touchGesture + touchGesture = next + if (next === undefined || prev === undefined) return + + const delta = prev - next + if (!delta) return + + const root = e.currentTarget + const target = e.target instanceof Element ? e.target : undefined + const nested = target?.closest("[data-scrollable]") + if (!nested || nested === root) { + props.onMarkScrollGesture(root) + return + } + + if (!(nested instanceof HTMLElement)) { + props.onMarkScrollGesture(root) + return + } + + if ( + shouldMarkBoundaryGesture({ + delta, + scrollTop: nested.scrollTop, + scrollHeight: nested.scrollHeight, + clientHeight: nested.clientHeight, + }) + ) { + props.onMarkScrollGesture(root) + } + }} + onTouchEnd={() => { + touchGesture = undefined + }} + onTouchCancel={() => { + touchGesture = undefined + }} + onPointerDown={(e) => { + if (e.target !== e.currentTarget) return + props.onMarkScrollGesture(e.currentTarget) + }} + onScroll={(e) => { + props.onScheduleScrollState(e.currentTarget) + if (!props.hasScrollGesture()) return + props.onAutoScrollHandleScroll() + props.onMarkScrollGesture(e.currentTarget) + if (props.isDesktop) props.onScrollSpyScroll() + }} + onClick={props.onAutoScrollInteraction} + class="relative min-w-0 w-full h-full overflow-y-auto session-scroller" + style={{ "--session-title-height": props.showHeader ? "40px" : "0px" }} + > + +
+
+
+ + + + + + {props.title} + + } + > + props.onTitleDraft(event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + void props.saveTitleEditor() + return + } + if (event.key === "Escape") { + event.preventDefault() + props.closeTitleEditor() + } + }} + onBlur={props.closeTitleEditor} + /> + + +
+ + {(id) => ( +
+ + + + + + { + if (!props.titleState.pendingRename) return + event.preventDefault() + props.onTitlePendingRename(false) + props.openTitleEditor() + }} + > + { + props.onTitlePendingRename(true) + props.onTitleMenuOpen(false) + }} + > + {props.t("common.rename")} + + props.onArchiveSession(id())}> + {props.t("common.archive")} + + + props.onDeleteSession(id())}> + {props.t("common.delete")} + + + + +
+ )} +
+
+
+
+ +
+ 0}> +
+ +
+
+ +
+ +
+
+ + {(message) => { + if (import.meta.env.DEV && props.onFirstTurnMount) { + onMount(() => props.onFirstTurnMount?.()) + } + + return ( +
{ + props.onRegisterMessage(el, message.id) + onCleanup(() => props.onUnregisterMessage(message.id)) + }} + classList={{ + "min-w-0 w-full max-w-full": true, + "md:max-w-200 3xl:max-w-[1200px]": props.centered, + }} + > + props.onToggleExpanded(message.id)} + classes={{ + root: "min-w-0 w-full relative", + content: "flex flex-col justify-between !overflow-visible", + container: "w-full px-4 md:px-6", + }} + /> +
+ ) + }} +
+
+
+
+ + ) +} diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx new file mode 100644 index 0000000000..a4232dd74e --- /dev/null +++ b/packages/app/src/pages/session/review-tab.tsx @@ -0,0 +1,158 @@ +import { createEffect, on, onCleanup, createSignal, type JSX } from "solid-js" +import type { FileDiff } from "@opencode-ai/sdk/v2" +import { SessionReview } from "@opencode-ai/ui/session-review" +import type { SelectedLineRange } from "@/context/file" +import { useSDK } from "@/context/sdk" +import { useLayout } from "@/context/layout" +import type { LineComment } from "@/context/comments" + +export type DiffStyle = "unified" | "split" + +export interface SessionReviewTabProps { + title?: JSX.Element + empty?: JSX.Element + diffs: () => FileDiff[] + view: () => ReturnType["view"]> + diffStyle: DiffStyle + onDiffStyleChange?: (style: DiffStyle) => void + onViewFile?: (file: string) => void + onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void + comments?: LineComment[] + focusedComment?: { file: string; id: string } | null + onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void + focusedFile?: string + onScrollRef?: (el: HTMLDivElement) => void + classes?: { + root?: string + header?: string + container?: string + } +} + +export function StickyAddButton(props: { children: JSX.Element }) { + const [stuck, setStuck] = createSignal(false) + let button: HTMLDivElement | undefined + + createEffect(() => { + const node = button + if (!node) return + + const scroll = node.parentElement + if (!scroll) return + + const handler = () => { + const rect = node.getBoundingClientRect() + const scrollRect = scroll.getBoundingClientRect() + setStuck(rect.right >= scrollRect.right && scroll.scrollWidth > scroll.clientWidth) + } + + scroll.addEventListener("scroll", handler, { passive: true }) + const observer = new ResizeObserver(handler) + observer.observe(scroll) + handler() + onCleanup(() => { + scroll.removeEventListener("scroll", handler) + observer.disconnect() + }) + }) + + return ( +
+ {props.children} +
+ ) +} + +export function SessionReviewTab(props: SessionReviewTabProps) { + let scroll: HTMLDivElement | undefined + let frame: number | undefined + let pending: { x: number; y: number } | undefined + + const sdk = useSDK() + + const readFile = async (path: string) => { + return sdk.client.file + .read({ path }) + .then((x) => x.data) + .catch(() => undefined) + } + + const restoreScroll = () => { + const el = scroll + if (!el) return + + const s = props.view().scroll("review") + if (!s) return + + if (el.scrollTop !== s.y) el.scrollTop = s.y + if (el.scrollLeft !== s.x) el.scrollLeft = s.x + } + + const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + pending = { + x: event.currentTarget.scrollLeft, + y: event.currentTarget.scrollTop, + } + if (frame !== undefined) return + + frame = requestAnimationFrame(() => { + frame = undefined + + const next = pending + pending = undefined + if (!next) return + + props.view().setScroll("review", next) + }) + } + + createEffect( + on( + () => props.diffs().length, + () => { + requestAnimationFrame(restoreScroll) + }, + { defer: true }, + ), + ) + + onCleanup(() => { + if (frame === undefined) return + cancelAnimationFrame(frame) + }) + + return ( + { + scroll = el + props.onScrollRef?.(el) + restoreScroll() + }} + onScroll={handleScroll} + onDiffRendered={() => requestAnimationFrame(restoreScroll)} + open={props.view().review.open()} + onOpenChange={props.view().review.setOpen} + classes={{ + root: props.classes?.root ?? "pb-40", + header: props.classes?.header ?? "px-6", + container: props.classes?.container ?? "px-6", + }} + diffs={props.diffs()} + diffStyle={props.diffStyle} + onDiffStyleChange={props.onDiffStyleChange} + onViewFile={props.onViewFile} + focusedFile={props.focusedFile} + readFile={readFile} + onLineComment={props.onLineComment} + comments={props.comments} + focusedComment={props.focusedComment} + onFocusedCommentChange={props.onFocusedCommentChange} + /> + ) +} diff --git a/packages/app/src/pages/session/scroll-spy.test.ts b/packages/app/src/pages/session/scroll-spy.test.ts new file mode 100644 index 0000000000..f3e6775cb4 --- /dev/null +++ b/packages/app/src/pages/session/scroll-spy.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "bun:test" +import { createScrollSpy, pickOffsetId, pickVisibleId } from "./scroll-spy" + +const rect = (top: number, height = 80): DOMRect => + ({ + x: 0, + y: top, + top, + left: 0, + right: 800, + bottom: top + height, + width: 800, + height, + toJSON: () => ({}), + }) as DOMRect + +const setRect = (el: Element, top: number, height = 80) => { + Object.defineProperty(el, "getBoundingClientRect", { + configurable: true, + value: () => rect(top, height), + }) +} + +describe("pickVisibleId", () => { + test("prefers higher intersection ratio", () => { + const id = pickVisibleId( + [ + { id: "a", ratio: 0.2, top: 100 }, + { id: "b", ratio: 0.8, top: 300 }, + ], + 120, + ) + + expect(id).toBe("b") + }) + + test("breaks ratio ties by nearest line", () => { + const id = pickVisibleId( + [ + { id: "a", ratio: 0.5, top: 90 }, + { id: "b", ratio: 0.5, top: 140 }, + ], + 130, + ) + + expect(id).toBe("b") + }) +}) + +describe("pickOffsetId", () => { + test("uses binary search cutoff", () => { + const id = pickOffsetId( + [ + { id: "a", top: 0 }, + { id: "b", top: 200 }, + { id: "c", top: 400 }, + ], + 350, + ) + + expect(id).toBe("b") + }) +}) + +describe("createScrollSpy fallback", () => { + test("tracks active id from offsets and dirty refresh", () => { + const active: string[] = [] + const root = document.createElement("div") as HTMLDivElement + const one = document.createElement("div") + const two = document.createElement("div") + const three = document.createElement("div") + + root.append(one, two, three) + document.body.append(root) + + Object.defineProperty(root, "scrollTop", { configurable: true, writable: true, value: 250 }) + setRect(root, 0, 800) + setRect(one, -250) + setRect(two, -50) + setRect(three, 150) + + const queue: FrameRequestCallback[] = [] + const flush = () => { + const run = [...queue] + queue.length = 0 + for (const cb of run) cb(0) + } + + const spy = createScrollSpy({ + onActive: (id) => active.push(id), + raf: (cb) => (queue.push(cb), queue.length), + caf: () => {}, + IntersectionObserver: undefined, + ResizeObserver: undefined, + MutationObserver: undefined, + }) + + spy.setContainer(root) + spy.register(one, "a") + spy.register(two, "b") + spy.register(three, "c") + spy.onScroll() + flush() + + expect(spy.getActiveId()).toBe("b") + expect(active.at(-1)).toBe("b") + + root.scrollTop = 450 + setRect(one, -450) + setRect(two, -250) + setRect(three, -50) + spy.onScroll() + flush() + expect(spy.getActiveId()).toBe("c") + + root.scrollTop = 250 + setRect(one, -250) + setRect(two, 250) + setRect(three, 150) + spy.markDirty() + spy.onScroll() + flush() + expect(spy.getActiveId()).toBe("a") + + spy.destroy() + }) +}) diff --git a/packages/app/src/pages/session/scroll-spy.ts b/packages/app/src/pages/session/scroll-spy.ts new file mode 100644 index 0000000000..8c52d77dce --- /dev/null +++ b/packages/app/src/pages/session/scroll-spy.ts @@ -0,0 +1,274 @@ +type Visible = { + id: string + ratio: number + top: number +} + +type Offset = { + id: string + top: number +} + +type Input = { + onActive: (id: string) => void + raf?: (cb: FrameRequestCallback) => number + caf?: (id: number) => void + IntersectionObserver?: typeof globalThis.IntersectionObserver + ResizeObserver?: typeof globalThis.ResizeObserver + MutationObserver?: typeof globalThis.MutationObserver +} + +export const pickVisibleId = (list: Visible[], line: number) => { + if (list.length === 0) return + + const sorted = [...list].sort((a, b) => { + if (b.ratio !== a.ratio) return b.ratio - a.ratio + + const da = Math.abs(a.top - line) + const db = Math.abs(b.top - line) + if (da !== db) return da - db + + return a.top - b.top + }) + + return sorted[0]?.id +} + +export const pickOffsetId = (list: Offset[], cutoff: number) => { + if (list.length === 0) return + + let lo = 0 + let hi = list.length - 1 + let out = 0 + + while (lo <= hi) { + const mid = (lo + hi) >> 1 + const top = list[mid]?.top + if (top === undefined) break + + if (top <= cutoff) { + out = mid + lo = mid + 1 + continue + } + + hi = mid - 1 + } + + return list[out]?.id +} + +export const createScrollSpy = (input: Input) => { + const raf = input.raf ?? requestAnimationFrame + const caf = input.caf ?? cancelAnimationFrame + const CtorIO = input.IntersectionObserver ?? globalThis.IntersectionObserver + const CtorRO = input.ResizeObserver ?? globalThis.ResizeObserver + const CtorMO = input.MutationObserver ?? globalThis.MutationObserver + + let root: HTMLDivElement | undefined + let io: IntersectionObserver | undefined + let ro: ResizeObserver | undefined + let mo: MutationObserver | undefined + let frame: number | undefined + let active: string | undefined + let dirty = true + + const node = new Map() + const id = new WeakMap() + const visible = new Map() + let offset: Offset[] = [] + + const schedule = () => { + if (frame !== undefined) return + frame = raf(() => { + frame = undefined + update() + }) + } + + const refreshOffset = () => { + const el = root + if (!el) { + offset = [] + dirty = false + return + } + + const base = el.getBoundingClientRect().top + offset = [...node].map(([next, item]) => ({ + id: next, + top: item.getBoundingClientRect().top - base + el.scrollTop, + })) + offset.sort((a, b) => a.top - b.top) + dirty = false + } + + const update = () => { + const el = root + if (!el) return + + const line = el.getBoundingClientRect().top + 100 + const next = + pickVisibleId( + [...visible].map(([k, v]) => ({ + id: k, + ratio: v.ratio, + top: v.top, + })), + line, + ) ?? + (() => { + if (dirty) refreshOffset() + return pickOffsetId(offset, el.scrollTop + 100) + })() + + if (!next || next === active) return + active = next + input.onActive(next) + } + + const observe = () => { + const el = root + if (!el) return + + io?.disconnect() + io = undefined + if (CtorIO) { + try { + io = new CtorIO( + (entries) => { + for (const entry of entries) { + const item = entry.target + if (!(item instanceof HTMLElement)) continue + const key = id.get(item) + if (!key) continue + + if (!entry.isIntersecting || entry.intersectionRatio <= 0) { + visible.delete(key) + continue + } + + visible.set(key, { + ratio: entry.intersectionRatio, + top: entry.boundingClientRect.top, + }) + } + + schedule() + }, + { + root: el, + threshold: [0, 0.25, 0.5, 0.75, 1], + }, + ) + } catch { + io = undefined + } + } + + if (io) { + for (const item of node.values()) io.observe(item) + } + + ro?.disconnect() + ro = undefined + if (CtorRO) { + ro = new CtorRO(() => { + dirty = true + schedule() + }) + ro.observe(el) + for (const item of node.values()) ro.observe(item) + } + + mo?.disconnect() + mo = undefined + if (CtorMO) { + mo = new CtorMO(() => { + dirty = true + schedule() + }) + mo.observe(el, { subtree: true, childList: true, characterData: true }) + } + + dirty = true + schedule() + } + + const setContainer = (el?: HTMLDivElement) => { + if (root === el) return + + root = el + visible.clear() + active = undefined + observe() + } + + const register = (el: HTMLElement, key: string) => { + const prev = node.get(key) + if (prev && prev !== el) { + io?.unobserve(prev) + ro?.unobserve(prev) + } + + node.set(key, el) + id.set(el, key) + if (io) io.observe(el) + if (ro) ro.observe(el) + dirty = true + schedule() + } + + const unregister = (key: string) => { + const item = node.get(key) + if (!item) return + + io?.unobserve(item) + ro?.unobserve(item) + node.delete(key) + visible.delete(key) + dirty = true + } + + const markDirty = () => { + dirty = true + schedule() + } + + const clear = () => { + for (const item of node.values()) { + io?.unobserve(item) + ro?.unobserve(item) + } + + node.clear() + visible.clear() + offset = [] + active = undefined + dirty = true + } + + const destroy = () => { + if (frame !== undefined) caf(frame) + frame = undefined + clear() + io?.disconnect() + ro?.disconnect() + mo?.disconnect() + io = undefined + ro = undefined + mo = undefined + root = undefined + } + + return { + setContainer, + register, + unregister, + onScroll: schedule, + markDirty, + clear, + destroy, + getActiveId: () => active, + } +} diff --git a/packages/app/src/pages/session/session-command-helpers.ts b/packages/app/src/pages/session/session-command-helpers.ts new file mode 100644 index 0000000000..b71a7b7688 --- /dev/null +++ b/packages/app/src/pages/session/session-command-helpers.ts @@ -0,0 +1,10 @@ +export const canAddSelectionContext = (input: { + active?: string + pathFromTab: (tab: string) => string | undefined + selectedLines: (path: string) => unknown +}) => { + if (!input.active) return false + const path = input.pathFromTab(input.active) + if (!path) return false + return input.selectedLines(path) != null +} diff --git a/packages/app/src/pages/session/session-mobile-tabs.tsx b/packages/app/src/pages/session/session-mobile-tabs.tsx new file mode 100644 index 0000000000..41f0582316 --- /dev/null +++ b/packages/app/src/pages/session/session-mobile-tabs.tsx @@ -0,0 +1,36 @@ +import { Match, Show, Switch } from "solid-js" +import { Tabs } from "@opencode-ai/ui/tabs" + +export function SessionMobileTabs(props: { + open: boolean + hasReview: boolean + reviewCount: number + onSession: () => void + onChanges: () => void + t: (key: string, vars?: Record) => string +}) { + return ( + + + + + {props.t("session.tab.session")} + + + + + {props.t("session.review.filesChanged", { count: props.reviewCount })} + + {props.t("session.review.change.other")} + + + + + + ) +} diff --git a/packages/app/src/pages/session/session-prompt-dock.test.ts b/packages/app/src/pages/session/session-prompt-dock.test.ts new file mode 100644 index 0000000000..b3a9945d66 --- /dev/null +++ b/packages/app/src/pages/session/session-prompt-dock.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { questionSubtitle } from "./session-prompt-helpers" + +describe("questionSubtitle", () => { + const t = (key: string) => { + if (key === "ui.common.question.one") return "question" + if (key === "ui.common.question.other") return "questions" + return key + } + + test("returns empty for zero", () => { + expect(questionSubtitle(0, t)).toBe("") + }) + + test("uses singular label", () => { + expect(questionSubtitle(1, t)).toBe("1 question") + }) + + test("uses plural label", () => { + expect(questionSubtitle(3, t)).toBe("3 questions") + }) +}) diff --git a/packages/app/src/pages/session/session-prompt-dock.tsx b/packages/app/src/pages/session/session-prompt-dock.tsx new file mode 100644 index 0000000000..6979570272 --- /dev/null +++ b/packages/app/src/pages/session/session-prompt-dock.tsx @@ -0,0 +1,137 @@ +import { For, Show, type ComponentProps } from "solid-js" +import { Button } from "@opencode-ai/ui/button" +import { BasicTool } from "@opencode-ai/ui/basic-tool" +import { PromptInput } from "@/components/prompt-input" +import { QuestionDock } from "@/components/question-dock" +import { questionSubtitle } from "@/pages/session/session-prompt-helpers" + +const questionDockRequest = (value: unknown) => value as ComponentProps["request"] + +export function SessionPromptDock(props: { + centered: boolean + questionRequest: () => { questions: unknown[] } | undefined + permissionRequest: () => { patterns: string[]; permission: string } | undefined + blocked: boolean + promptReady: boolean + handoffPrompt?: string + t: (key: string, vars?: Record) => string + responding: boolean + onDecide: (response: "once" | "always" | "reject") => void + inputRef: (el: HTMLDivElement) => void + newSessionWorktree: string + onNewSessionWorktreeReset: () => void + onSubmit: () => void + setPromptDockRef: (el: HTMLDivElement) => void +}) { + return ( +
+
+ + {(req) => { + const subtitle = questionSubtitle(req.questions.length, (key) => props.t(key)) + return ( +
+ + +
+ ) + }} +
+ + + {(perm) => ( +
+ + 0}> +
+ + {(pattern) => {pattern}} + +
+
+ +
+ {props.t("settings.permissions.tool.doom_loop.description")} +
+
+
+
+
+ + + +
+
+
+ )} +
+ + + + {props.handoffPrompt || props.t("prompt.loading")} +
+ } + > + + + +
+ + ) +} diff --git a/packages/app/src/pages/session/session-prompt-helpers.ts b/packages/app/src/pages/session/session-prompt-helpers.ts new file mode 100644 index 0000000000..ac3234c939 --- /dev/null +++ b/packages/app/src/pages/session/session-prompt-helpers.ts @@ -0,0 +1,4 @@ +export const questionSubtitle = (count: number, t: (key: string) => string) => { + if (count === 0) return "" + return `${count} ${t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}` +} diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx new file mode 100644 index 0000000000..573680dec6 --- /dev/null +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -0,0 +1,306 @@ +import { For, Match, Show, Switch, createMemo, onCleanup, type JSX, type ValidComponent } from "solid-js" +import { Tabs } from "@opencode-ai/ui/tabs" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { ResizeHandle } from "@opencode-ai/ui/resize-handle" +import { Mark } from "@opencode-ai/ui/logo" +import FileTree from "@/components/file-tree" +import { SessionContextUsage } from "@/components/session-context-usage" +import { SessionContextTab, SortableTab, FileVisual } from "@/components/session" +import { DialogSelectFile } from "@/components/dialog-select-file" +import { createFileTabListSync } from "@/pages/session/file-tab-scroll" +import { FileTabContent } from "@/pages/session/file-tabs" +import { StickyAddButton } from "@/pages/session/review-tab" +import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" +import { ConstrainDragYAxis } from "@/utils/solid-dnd" +import type { DragEvent } from "@thisbeyond/solid-dnd" +import { useComments } from "@/context/comments" +import { useCommand } from "@/context/command" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useFile, type SelectedLineRange } from "@/context/file" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { useSync } from "@/context/sync" + +export function SessionSidePanel(props: { + open: boolean + language: ReturnType + layout: ReturnType + command: ReturnType + dialog: ReturnType + file: ReturnType + comments: ReturnType + sync: ReturnType + hasReview: boolean + reviewCount: number + reviewTab: boolean + contextOpen: () => boolean + openedTabs: () => string[] + activeTab: () => string + activeFileTab: () => string | undefined + tabs: () => ReturnType["tabs"]> + openTab: (value: string) => void + showAllFiles: () => void + reviewPanel: () => JSX.Element + messages: () => unknown[] + visibleUserMessages: () => unknown[] + view: () => ReturnType["view"]> + info: () => unknown + handoffFiles: () => Record | undefined + codeComponent: NonNullable + addCommentToContext: (input: { + file: string + selection: SelectedLineRange + comment: string + preview?: string + origin?: "review" | "file" + }) => void + activeDraggable: () => string | undefined + onDragStart: (event: unknown) => void + onDragEnd: () => void + onDragOver: (event: DragEvent) => void + fileTreeTab: () => "changes" | "all" + setFileTreeTabValue: (value: string) => void + diffsReady: boolean + diffFiles: string[] + kinds: Map + activeDiff?: string + focusReviewDiff: (path: string) => void +}) { + return ( + + + + ) +} diff --git a/packages/app/src/pages/session/terminal-label.ts b/packages/app/src/pages/session/terminal-label.ts new file mode 100644 index 0000000000..6d336769b1 --- /dev/null +++ b/packages/app/src/pages/session/terminal-label.ts @@ -0,0 +1,16 @@ +export const terminalTabLabel = (input: { + title?: string + titleNumber?: number + t: (key: string, vars?: Record) => string +}) => { + const title = input.title ?? "" + const number = input.titleNumber ?? 0 + const match = title.match(/^Terminal (\d+)$/) + const parsed = match ? Number(match[1]) : undefined + const isDefaultTitle = Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number + + if (title && !isDefaultTitle) return title + if (number > 0) return input.t("terminal.title.numbered", { number }) + if (title) return title + return input.t("terminal.title") +} diff --git a/packages/app/src/pages/session/terminal-panel.test.ts b/packages/app/src/pages/session/terminal-panel.test.ts new file mode 100644 index 0000000000..43eeec32f2 --- /dev/null +++ b/packages/app/src/pages/session/terminal-panel.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test" +import { terminalTabLabel } from "./terminal-label" + +const t = (key: string, vars?: Record) => { + if (key === "terminal.title.numbered") return `Terminal ${vars?.number}` + if (key === "terminal.title") return "Terminal" + return key +} + +describe("terminalTabLabel", () => { + test("returns custom title unchanged", () => { + const label = terminalTabLabel({ title: "server", titleNumber: 3, t }) + expect(label).toBe("server") + }) + + test("normalizes default numbered title", () => { + const label = terminalTabLabel({ title: "Terminal 2", titleNumber: 2, t }) + expect(label).toBe("Terminal 2") + }) + + test("falls back to generic title", () => { + const label = terminalTabLabel({ title: "", titleNumber: 0, t }) + expect(label).toBe("Terminal") + }) +}) diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx new file mode 100644 index 0000000000..09095d689c --- /dev/null +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -0,0 +1,169 @@ +import { createMemo, For, Show } from "solid-js" +import { Tabs } from "@opencode-ai/ui/tabs" +import { ResizeHandle } from "@opencode-ai/ui/resize-handle" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" +import type { DragEvent } from "@thisbeyond/solid-dnd" +import { ConstrainDragYAxis } from "@/utils/solid-dnd" +import { SortableTerminalTab } from "@/components/session" +import { Terminal } from "@/components/terminal" +import { useTerminal, type LocalPTY } from "@/context/terminal" +import { useLanguage } from "@/context/language" +import { useCommand } from "@/context/command" +import { terminalTabLabel } from "@/pages/session/terminal-label" + +export function TerminalPanel(props: { + open: boolean + height: number + resize: (value: number) => void + close: () => void + terminal: ReturnType + language: ReturnType + command: ReturnType + handoff: () => string[] + activeTerminalDraggable: () => string | undefined + handleTerminalDragStart: (event: unknown) => void + handleTerminalDragOver: (event: DragEvent) => void + handleTerminalDragEnd: () => void + onCloseTab: () => void +}) { + return ( + +
+ + +
+ + {(title) => ( +
+ {title} +
+ )} +
+
+
+ {props.language.t("common.loading")} + {props.language.t("common.loading.ellipsis")} +
+
+
+ {props.language.t("terminal.loading")} +
+
+ } + > + + + +
+ props.terminal.open(id)} + class="!h-auto !flex-none" + > + + t.id)}> + + {(pty) => ( + { + props.close() + props.onCloseTab() + }} + /> + )} + + +
+ + + +
+
+
+
+ + {(pty) => ( +
+ + props.terminal.clone(pty.id)} + /> + +
+ )} +
+
+
+ + + {(draggedId) => { + const pty = createMemo(() => props.terminal.all().find((t: LocalPTY) => t.id === draggedId())) + return ( + + {(t) => ( +
+ {terminalTabLabel({ + title: t().title, + titleNumber: t().titleNumber, + t: props.language.t as ( + key: string, + vars?: Record, + ) => string, + })} +
+ )} +
+ ) + }} +
+
+
+
+
+
+ ) +} diff --git a/packages/app/src/pages/session/use-session-commands.test.ts b/packages/app/src/pages/session/use-session-commands.test.ts new file mode 100644 index 0000000000..ada1871e1c --- /dev/null +++ b/packages/app/src/pages/session/use-session-commands.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test" +import { canAddSelectionContext } from "./session-command-helpers" + +describe("canAddSelectionContext", () => { + test("returns false without active tab", () => { + expect( + canAddSelectionContext({ + active: undefined, + pathFromTab: () => "src/a.ts", + selectedLines: () => ({ start: 1, end: 1 }), + }), + ).toBe(false) + }) + + test("returns false when active tab is not a file", () => { + expect( + canAddSelectionContext({ + active: "context", + pathFromTab: () => undefined, + selectedLines: () => ({ start: 1, end: 1 }), + }), + ).toBe(false) + }) + + test("returns false without selected lines", () => { + expect( + canAddSelectionContext({ + active: "file://src/a.ts", + pathFromTab: () => "src/a.ts", + selectedLines: () => null, + }), + ).toBe(false) + }) + + test("returns true when file and selection exist", () => { + expect( + canAddSelectionContext({ + active: "file://src/a.ts", + pathFromTab: () => "src/a.ts", + selectedLines: () => ({ start: 1, end: 2 }), + }), + ).toBe(true) + }) +}) diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx new file mode 100644 index 0000000000..ae845a657f --- /dev/null +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -0,0 +1,439 @@ +import { createMemo } from "solid-js" +import { useNavigate, useParams } from "@solidjs/router" +import { useCommand } from "@/context/command" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useFile, selectionFromLines, type FileSelection } from "@/context/file" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { useLocal } from "@/context/local" +import { usePermission } from "@/context/permission" +import { usePrompt } from "@/context/prompt" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { useTerminal } from "@/context/terminal" +import { DialogSelectFile } from "@/components/dialog-select-file" +import { DialogSelectModel } from "@/components/dialog-select-model" +import { DialogSelectMcp } from "@/components/dialog-select-mcp" +import { DialogFork } from "@/components/dialog-fork" +import { showToast } from "@opencode-ai/ui/toast" +import { findLast } from "@opencode-ai/util/array" +import { extractPromptFromParts } from "@/utils/prompt" +import { UserMessage } from "@opencode-ai/sdk/v2" +import { combineCommandSections } from "@/pages/session/helpers" +import { canAddSelectionContext } from "@/pages/session/session-command-helpers" + +export const useSessionCommands = (input: { + command: ReturnType + dialog: ReturnType + file: ReturnType + language: ReturnType + local: ReturnType + permission: ReturnType + prompt: ReturnType + sdk: ReturnType + sync: ReturnType + terminal: ReturnType + layout: ReturnType + params: ReturnType + navigate: ReturnType + tabs: () => ReturnType["tabs"]> + view: () => ReturnType["view"]> + info: () => { revert?: { messageID?: string }; share?: { url?: string } } | undefined + status: () => { type: string } + userMessages: () => UserMessage[] + visibleUserMessages: () => UserMessage[] + activeMessage: () => UserMessage | undefined + showAllFiles: () => void + navigateMessageByOffset: (offset: number) => void + setExpanded: (id: string, fn: (open: boolean | undefined) => boolean) => void + setActiveMessage: (message: UserMessage | undefined) => void + addSelectionToContext: (path: string, selection: FileSelection) => void +}) => { + const sessionCommands = createMemo(() => [ + { + id: "session.new", + title: input.language.t("command.session.new"), + category: input.language.t("command.category.session"), + keybind: "mod+shift+s", + slash: "new", + onSelect: () => input.navigate(`/${input.params.dir}/session`), + }, + ]) + + const fileCommands = createMemo(() => [ + { + id: "file.open", + title: input.language.t("command.file.open"), + description: input.language.t("palette.search.placeholder"), + category: input.language.t("command.category.file"), + keybind: "mod+p", + slash: "open", + onSelect: () => input.dialog.show(() => ), + }, + { + id: "tab.close", + title: input.language.t("command.tab.close"), + category: input.language.t("command.category.file"), + keybind: "mod+w", + disabled: !input.tabs().active(), + onSelect: () => { + const active = input.tabs().active() + if (!active) return + input.tabs().close(active) + }, + }, + ]) + + const contextCommands = createMemo(() => [ + { + id: "context.addSelection", + title: input.language.t("command.context.addSelection"), + description: input.language.t("command.context.addSelection.description"), + category: input.language.t("command.category.context"), + keybind: "mod+shift+l", + disabled: !canAddSelectionContext({ + active: input.tabs().active(), + pathFromTab: input.file.pathFromTab, + selectedLines: input.file.selectedLines, + }), + onSelect: () => { + const active = input.tabs().active() + if (!active) return + const path = input.file.pathFromTab(active) + if (!path) return + + const range = input.file.selectedLines(path) + if (!range) { + showToast({ + title: input.language.t("toast.context.noLineSelection.title"), + description: input.language.t("toast.context.noLineSelection.description"), + }) + return + } + + input.addSelectionToContext(path, selectionFromLines(range)) + }, + }, + ]) + + const viewCommands = createMemo(() => [ + { + id: "terminal.toggle", + title: input.language.t("command.terminal.toggle"), + description: "", + category: input.language.t("command.category.view"), + keybind: "ctrl+`", + slash: "terminal", + onSelect: () => input.view().terminal.toggle(), + }, + { + id: "review.toggle", + title: input.language.t("command.review.toggle"), + description: "", + category: input.language.t("command.category.view"), + keybind: "mod+shift+r", + onSelect: () => input.view().reviewPanel.toggle(), + }, + { + id: "fileTree.toggle", + title: input.language.t("command.fileTree.toggle"), + description: "", + category: input.language.t("command.category.view"), + onSelect: () => { + const opening = !input.layout.fileTree.opened() + if (opening && !input.view().reviewPanel.opened()) input.view().reviewPanel.open() + input.layout.fileTree.toggle() + }, + }, + { + id: "terminal.new", + title: input.language.t("command.terminal.new"), + description: input.language.t("command.terminal.new.description"), + category: input.language.t("command.category.terminal"), + keybind: "ctrl+alt+t", + onSelect: () => { + if (input.terminal.all().length > 0) input.terminal.new() + input.view().terminal.open() + }, + }, + { + id: "steps.toggle", + title: input.language.t("command.steps.toggle"), + description: input.language.t("command.steps.toggle.description"), + category: input.language.t("command.category.view"), + keybind: "mod+e", + slash: "steps", + disabled: !input.params.id, + onSelect: () => { + const msg = input.activeMessage() + if (!msg) return + input.setExpanded(msg.id, (open: boolean | undefined) => !open) + }, + }, + ]) + + const messageCommands = createMemo(() => [ + { + id: "message.previous", + title: input.language.t("command.message.previous"), + description: input.language.t("command.message.previous.description"), + category: input.language.t("command.category.session"), + keybind: "mod+arrowup", + disabled: !input.params.id, + onSelect: () => input.navigateMessageByOffset(-1), + }, + { + id: "message.next", + title: input.language.t("command.message.next"), + description: input.language.t("command.message.next.description"), + category: input.language.t("command.category.session"), + keybind: "mod+arrowdown", + disabled: !input.params.id, + onSelect: () => input.navigateMessageByOffset(1), + }, + ]) + + const agentCommands = createMemo(() => [ + { + id: "model.choose", + title: input.language.t("command.model.choose"), + description: input.language.t("command.model.choose.description"), + category: input.language.t("command.category.model"), + keybind: "mod+'", + slash: "model", + onSelect: () => input.dialog.show(() => ), + }, + { + id: "mcp.toggle", + title: input.language.t("command.mcp.toggle"), + description: input.language.t("command.mcp.toggle.description"), + category: input.language.t("command.category.mcp"), + keybind: "mod+;", + slash: "mcp", + onSelect: () => input.dialog.show(() => ), + }, + { + id: "agent.cycle", + title: input.language.t("command.agent.cycle"), + description: input.language.t("command.agent.cycle.description"), + category: input.language.t("command.category.agent"), + keybind: "mod+.", + slash: "agent", + onSelect: () => input.local.agent.move(1), + }, + { + id: "agent.cycle.reverse", + title: input.language.t("command.agent.cycle.reverse"), + description: input.language.t("command.agent.cycle.reverse.description"), + category: input.language.t("command.category.agent"), + keybind: "shift+mod+.", + onSelect: () => input.local.agent.move(-1), + }, + { + id: "model.variant.cycle", + title: input.language.t("command.model.variant.cycle"), + description: input.language.t("command.model.variant.cycle.description"), + category: input.language.t("command.category.model"), + keybind: "shift+mod+d", + onSelect: () => { + input.local.model.variant.cycle() + }, + }, + ]) + + const permissionCommands = createMemo(() => [ + { + id: "permissions.autoaccept", + title: + input.params.id && input.permission.isAutoAccepting(input.params.id, input.sdk.directory) + ? input.language.t("command.permissions.autoaccept.disable") + : input.language.t("command.permissions.autoaccept.enable"), + category: input.language.t("command.category.permissions"), + keybind: "mod+shift+a", + disabled: !input.params.id || !input.permission.permissionsEnabled(), + onSelect: () => { + const sessionID = input.params.id + if (!sessionID) return + input.permission.toggleAutoAccept(sessionID, input.sdk.directory) + showToast({ + title: input.permission.isAutoAccepting(sessionID, input.sdk.directory) + ? input.language.t("toast.permissions.autoaccept.on.title") + : input.language.t("toast.permissions.autoaccept.off.title"), + description: input.permission.isAutoAccepting(sessionID, input.sdk.directory) + ? input.language.t("toast.permissions.autoaccept.on.description") + : input.language.t("toast.permissions.autoaccept.off.description"), + }) + }, + }, + ]) + + const sessionActionCommands = createMemo(() => [ + { + id: "session.undo", + title: input.language.t("command.session.undo"), + description: input.language.t("command.session.undo.description"), + category: input.language.t("command.category.session"), + slash: "undo", + disabled: !input.params.id || input.visibleUserMessages().length === 0, + onSelect: async () => { + const sessionID = input.params.id + if (!sessionID) return + if (input.status()?.type !== "idle") { + await input.sdk.client.session.abort({ sessionID }).catch(() => {}) + } + const revert = input.info()?.revert?.messageID + const message = findLast(input.userMessages(), (x) => !revert || x.id < revert) + if (!message) return + await input.sdk.client.session.revert({ sessionID, messageID: message.id }) + const parts = input.sync.data.part[message.id] + if (parts) { + const restored = extractPromptFromParts(parts, { directory: input.sdk.directory }) + input.prompt.set(restored) + } + const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id) + input.setActiveMessage(priorMessage) + }, + }, + { + id: "session.redo", + title: input.language.t("command.session.redo"), + description: input.language.t("command.session.redo.description"), + category: input.language.t("command.category.session"), + slash: "redo", + disabled: !input.params.id || !input.info()?.revert?.messageID, + onSelect: async () => { + const sessionID = input.params.id + if (!sessionID) return + const revertMessageID = input.info()?.revert?.messageID + if (!revertMessageID) return + const nextMessage = input.userMessages().find((x) => x.id > revertMessageID) + if (!nextMessage) { + await input.sdk.client.session.unrevert({ sessionID }) + input.prompt.reset() + const lastMsg = findLast(input.userMessages(), (x) => x.id >= revertMessageID) + input.setActiveMessage(lastMsg) + return + } + await input.sdk.client.session.revert({ sessionID, messageID: nextMessage.id }) + const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id) + input.setActiveMessage(priorMsg) + }, + }, + { + id: "session.compact", + title: input.language.t("command.session.compact"), + description: input.language.t("command.session.compact.description"), + category: input.language.t("command.category.session"), + slash: "compact", + disabled: !input.params.id || input.visibleUserMessages().length === 0, + onSelect: async () => { + const sessionID = input.params.id + if (!sessionID) return + const model = input.local.model.current() + if (!model) { + showToast({ + title: input.language.t("toast.model.none.title"), + description: input.language.t("toast.model.none.description"), + }) + return + } + await input.sdk.client.session.summarize({ + sessionID, + modelID: model.id, + providerID: model.provider.id, + }) + }, + }, + { + id: "session.fork", + title: input.language.t("command.session.fork"), + description: input.language.t("command.session.fork.description"), + category: input.language.t("command.category.session"), + slash: "fork", + disabled: !input.params.id || input.visibleUserMessages().length === 0, + onSelect: () => input.dialog.show(() => ), + }, + ]) + + const shareCommands = createMemo(() => { + if (input.sync.data.config.share === "disabled") return [] + return [ + { + id: "session.share", + title: input.language.t("command.session.share"), + description: input.language.t("command.session.share.description"), + category: input.language.t("command.category.session"), + slash: "share", + disabled: !input.params.id || !!input.info()?.share?.url, + onSelect: async () => { + if (!input.params.id) return + await input.sdk.client.session + .share({ sessionID: input.params.id }) + .then((res) => { + navigator.clipboard.writeText(res.data!.share!.url).catch(() => + showToast({ + title: input.language.t("toast.session.share.copyFailed.title"), + variant: "error", + }), + ) + }) + .then(() => + showToast({ + title: input.language.t("toast.session.share.success.title"), + description: input.language.t("toast.session.share.success.description"), + variant: "success", + }), + ) + .catch(() => + showToast({ + title: input.language.t("toast.session.share.failed.title"), + description: input.language.t("toast.session.share.failed.description"), + variant: "error", + }), + ) + }, + }, + { + id: "session.unshare", + title: input.language.t("command.session.unshare"), + description: input.language.t("command.session.unshare.description"), + category: input.language.t("command.category.session"), + slash: "unshare", + disabled: !input.params.id || !input.info()?.share?.url, + onSelect: async () => { + if (!input.params.id) return + await input.sdk.client.session + .unshare({ sessionID: input.params.id }) + .then(() => + showToast({ + title: input.language.t("toast.session.unshare.success.title"), + description: input.language.t("toast.session.unshare.success.description"), + variant: "success", + }), + ) + .catch(() => + showToast({ + title: input.language.t("toast.session.unshare.failed.title"), + description: input.language.t("toast.session.unshare.failed.description"), + variant: "error", + }), + ) + }, + }, + ] + }) + + input.command.register("session", () => + combineCommandSections([ + sessionCommands(), + fileCommands(), + contextCommands(), + viewCommands(), + messageCommands(), + agentCommands(), + permissionCommands(), + sessionActionCommands(), + shareCommands(), + ]), + ) +} diff --git a/packages/app/src/pages/session/use-session-hash-scroll.test.ts b/packages/app/src/pages/session/use-session-hash-scroll.test.ts new file mode 100644 index 0000000000..844f5451e3 --- /dev/null +++ b/packages/app/src/pages/session/use-session-hash-scroll.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test" +import { messageIdFromHash } from "./use-session-hash-scroll" + +describe("messageIdFromHash", () => { + test("parses hash with leading #", () => { + expect(messageIdFromHash("#message-abc123")).toBe("abc123") + }) + + test("parses raw hash fragment", () => { + expect(messageIdFromHash("message-42")).toBe("42") + }) + + test("ignores non-message anchors", () => { + expect(messageIdFromHash("#review-panel")).toBeUndefined() + }) +}) diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts new file mode 100644 index 0000000000..8952bbd98b --- /dev/null +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -0,0 +1,174 @@ +import { createEffect, on, onCleanup } from "solid-js" +import { UserMessage } from "@opencode-ai/sdk/v2" + +export const messageIdFromHash = (hash: string) => { + const value = hash.startsWith("#") ? hash.slice(1) : hash + const match = value.match(/^message-(.+)$/) + if (!match) return + return match[1] +} + +export const useSessionHashScroll = (input: { + sessionKey: () => string + sessionID: () => string | undefined + messagesReady: () => boolean + visibleUserMessages: () => UserMessage[] + turnStart: () => number + currentMessageId: () => string | undefined + pendingMessage: () => string | undefined + setPendingMessage: (value: string | undefined) => void + setActiveMessage: (message: UserMessage | undefined) => void + setTurnStart: (value: number) => void + scheduleTurnBackfill: () => void + autoScroll: { pause: () => void; forceScrollToBottom: () => void } + scroller: () => HTMLDivElement | undefined + anchor: (id: string) => string + scheduleScrollState: (el: HTMLDivElement) => void + consumePendingMessage: (key: string) => string | undefined +}) => { + const clearMessageHash = () => { + if (!window.location.hash) return + window.history.replaceState(null, "", window.location.href.replace(/#.*$/, "")) + } + + const updateHash = (id: string) => { + window.history.replaceState(null, "", `#${input.anchor(id)}`) + } + + const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { + const root = input.scroller() + if (!root) return false + + const a = el.getBoundingClientRect() + const b = root.getBoundingClientRect() + const top = a.top - b.top + root.scrollTop + root.scrollTo({ top, behavior }) + return true + } + + const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { + input.setActiveMessage(message) + + const msgs = input.visibleUserMessages() + const index = msgs.findIndex((m) => m.id === message.id) + if (index !== -1 && index < input.turnStart()) { + input.setTurnStart(index) + input.scheduleTurnBackfill() + + requestAnimationFrame(() => { + const el = document.getElementById(input.anchor(message.id)) + if (!el) { + requestAnimationFrame(() => { + const next = document.getElementById(input.anchor(message.id)) + if (!next) return + scrollToElement(next, behavior) + }) + return + } + scrollToElement(el, behavior) + }) + + updateHash(message.id) + return + } + + const el = document.getElementById(input.anchor(message.id)) + if (!el) { + updateHash(message.id) + requestAnimationFrame(() => { + const next = document.getElementById(input.anchor(message.id)) + if (!next) return + if (!scrollToElement(next, behavior)) return + }) + return + } + if (scrollToElement(el, behavior)) { + updateHash(message.id) + return + } + + requestAnimationFrame(() => { + const next = document.getElementById(input.anchor(message.id)) + if (!next) return + if (!scrollToElement(next, behavior)) return + }) + updateHash(message.id) + } + + const applyHash = (behavior: ScrollBehavior) => { + const hash = window.location.hash.slice(1) + if (!hash) { + input.autoScroll.forceScrollToBottom() + const el = input.scroller() + if (el) input.scheduleScrollState(el) + return + } + + const messageId = messageIdFromHash(hash) + if (messageId) { + input.autoScroll.pause() + const msg = input.visibleUserMessages().find((m) => m.id === messageId) + if (msg) { + scrollToMessage(msg, behavior) + return + } + return + } + + const target = document.getElementById(hash) + if (target) { + input.autoScroll.pause() + scrollToElement(target, behavior) + return + } + + input.autoScroll.forceScrollToBottom() + const el = input.scroller() + if (el) input.scheduleScrollState(el) + } + + createEffect( + on(input.sessionKey, (key) => { + if (!input.sessionID()) return + const messageID = input.consumePendingMessage(key) + if (!messageID) return + input.setPendingMessage(messageID) + }), + ) + + createEffect(() => { + if (!input.sessionID() || !input.messagesReady()) return + requestAnimationFrame(() => applyHash("auto")) + }) + + createEffect(() => { + if (!input.sessionID() || !input.messagesReady()) return + + input.visibleUserMessages().length + input.turnStart() + + const targetId = input.pendingMessage() ?? messageIdFromHash(window.location.hash) + if (!targetId) return + if (input.currentMessageId() === targetId) return + + const msg = input.visibleUserMessages().find((m) => m.id === targetId) + if (!msg) return + + if (input.pendingMessage() === targetId) input.setPendingMessage(undefined) + input.autoScroll.pause() + requestAnimationFrame(() => scrollToMessage(msg, "auto")) + }) + + createEffect(() => { + if (!input.sessionID() || !input.messagesReady()) return + const handler = () => requestAnimationFrame(() => applyHash("auto")) + window.addEventListener("hashchange", handler) + onCleanup(() => window.removeEventListener("hashchange", handler)) + }) + + return { + clearMessageHash, + scrollToMessage, + applyHash, + } +} diff --git a/packages/app/src/utils/runtime-adapters.test.ts b/packages/app/src/utils/runtime-adapters.test.ts new file mode 100644 index 0000000000..9f408b8eb7 --- /dev/null +++ b/packages/app/src/utils/runtime-adapters.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test" +import { + disposeIfDisposable, + getHoveredLinkText, + getSpeechRecognitionCtor, + hasSetOption, + isDisposable, + setOptionIfSupported, +} from "./runtime-adapters" + +describe("runtime adapters", () => { + test("detects and disposes disposable values", () => { + let count = 0 + const value = { + dispose: () => { + count += 1 + }, + } + expect(isDisposable(value)).toBe(true) + disposeIfDisposable(value) + expect(count).toBe(1) + }) + + test("ignores non-disposable values", () => { + expect(isDisposable({ dispose: "nope" })).toBe(false) + expect(() => disposeIfDisposable({ dispose: "nope" })).not.toThrow() + }) + + test("sets options only when setter exists", () => { + const calls: Array<[string, unknown]> = [] + const value = { + setOption: (key: string, next: unknown) => { + calls.push([key, next]) + }, + } + expect(hasSetOption(value)).toBe(true) + setOptionIfSupported(value, "fontFamily", "Berkeley Mono") + expect(calls).toEqual([["fontFamily", "Berkeley Mono"]]) + expect(() => setOptionIfSupported({}, "fontFamily", "Berkeley Mono")).not.toThrow() + }) + + test("reads hovered link text safely", () => { + expect(getHoveredLinkText({ currentHoveredLink: { text: "https://example.com" } })).toBe("https://example.com") + expect(getHoveredLinkText({ currentHoveredLink: { text: 1 } })).toBeUndefined() + expect(getHoveredLinkText(null)).toBeUndefined() + }) + + test("resolves speech recognition constructor with webkit precedence", () => { + class SpeechCtor {} + class WebkitCtor {} + const ctor = getSpeechRecognitionCtor({ + SpeechRecognition: SpeechCtor, + webkitSpeechRecognition: WebkitCtor, + }) + expect(ctor).toBe(WebkitCtor) + }) + + test("returns undefined when no valid speech constructor exists", () => { + expect(getSpeechRecognitionCtor({ SpeechRecognition: "nope" })).toBeUndefined() + expect(getSpeechRecognitionCtor(undefined)).toBeUndefined() + }) +}) diff --git a/packages/app/src/utils/runtime-adapters.ts b/packages/app/src/utils/runtime-adapters.ts new file mode 100644 index 0000000000..4c74da5dc1 --- /dev/null +++ b/packages/app/src/utils/runtime-adapters.ts @@ -0,0 +1,39 @@ +type RecordValue = Record + +const isRecord = (value: unknown): value is RecordValue => { + return typeof value === "object" && value !== null +} + +export const isDisposable = (value: unknown): value is { dispose: () => void } => { + return isRecord(value) && typeof value.dispose === "function" +} + +export const disposeIfDisposable = (value: unknown) => { + if (!isDisposable(value)) return + value.dispose() +} + +export const hasSetOption = (value: unknown): value is { setOption: (key: string, next: unknown) => void } => { + return isRecord(value) && typeof value.setOption === "function" +} + +export const setOptionIfSupported = (value: unknown, key: string, next: unknown) => { + if (!hasSetOption(value)) return + value.setOption(key, next) +} + +export const getHoveredLinkText = (value: unknown) => { + if (!isRecord(value)) return + const link = value.currentHoveredLink + if (!isRecord(link)) return + if (typeof link.text !== "string") return + return link.text +} + +export const getSpeechRecognitionCtor = (value: unknown): (new () => T) | undefined => { + if (!isRecord(value)) return + const ctor = + typeof value.webkitSpeechRecognition === "function" ? value.webkitSpeechRecognition : value.SpeechRecognition + if (typeof ctor !== "function") return + return ctor as new () => T +} diff --git a/packages/app/src/utils/scoped-cache.test.ts b/packages/app/src/utils/scoped-cache.test.ts new file mode 100644 index 0000000000..0c6189dafe --- /dev/null +++ b/packages/app/src/utils/scoped-cache.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test" +import { createScopedCache } from "./scoped-cache" + +describe("createScopedCache", () => { + test("evicts least-recently-used entry when max is reached", () => { + const disposed: string[] = [] + const cache = createScopedCache((key) => ({ key }), { + maxEntries: 2, + dispose: (value) => disposed.push(value.key), + }) + + const a = cache.get("a") + const b = cache.get("b") + expect(a.key).toBe("a") + expect(b.key).toBe("b") + + cache.get("a") + const c = cache.get("c") + + expect(c.key).toBe("c") + expect(cache.peek("a")?.key).toBe("a") + expect(cache.peek("b")).toBeUndefined() + expect(cache.peek("c")?.key).toBe("c") + expect(disposed).toEqual(["b"]) + }) + + test("disposes entries on delete and clear", () => { + const disposed: string[] = [] + const cache = createScopedCache((key) => ({ key }), { + dispose: (value) => disposed.push(value.key), + }) + + cache.get("a") + cache.get("b") + + const removed = cache.delete("a") + expect(removed?.key).toBe("a") + expect(cache.peek("a")).toBeUndefined() + + cache.clear() + expect(cache.peek("b")).toBeUndefined() + expect(disposed).toEqual(["a", "b"]) + }) + + test("expires stale entries with ttl and recreates on get", () => { + let clock = 0 + let count = 0 + const disposed: string[] = [] + const cache = createScopedCache((key) => ({ key, count: ++count }), { + ttlMs: 10, + now: () => clock, + dispose: (value) => disposed.push(`${value.key}:${value.count}`), + }) + + const first = cache.get("a") + expect(first.count).toBe(1) + + clock = 9 + expect(cache.peek("a")?.count).toBe(1) + + clock = 11 + expect(cache.peek("a")).toBeUndefined() + expect(disposed).toEqual(["a:1"]) + + const second = cache.get("a") + expect(second.count).toBe(2) + expect(disposed).toEqual(["a:1"]) + }) +}) diff --git a/packages/app/src/utils/scoped-cache.ts b/packages/app/src/utils/scoped-cache.ts new file mode 100644 index 0000000000..224c363c1e --- /dev/null +++ b/packages/app/src/utils/scoped-cache.ts @@ -0,0 +1,104 @@ +type ScopedCacheOptions = { + maxEntries?: number + ttlMs?: number + dispose?: (value: T, key: string) => void + now?: () => number +} + +type Entry = { + value: T + touchedAt: number +} + +export function createScopedCache(createValue: (key: string) => T, options: ScopedCacheOptions = {}) { + const store = new Map>() + const now = options.now ?? Date.now + + const dispose = (key: string, entry: Entry) => { + options.dispose?.(entry.value, key) + } + + const expired = (entry: Entry) => { + if (options.ttlMs === undefined) return false + return now() - entry.touchedAt >= options.ttlMs + } + + const sweep = () => { + if (options.ttlMs === undefined) return + for (const [key, entry] of store) { + if (!expired(entry)) continue + store.delete(key) + dispose(key, entry) + } + } + + const touch = (key: string, entry: Entry) => { + entry.touchedAt = now() + store.delete(key) + store.set(key, entry) + } + + const prune = () => { + if (options.maxEntries === undefined) return + while (store.size > options.maxEntries) { + const key = store.keys().next().value + if (!key) return + const entry = store.get(key) + store.delete(key) + if (!entry) continue + dispose(key, entry) + } + } + + const remove = (key: string) => { + const entry = store.get(key) + if (!entry) return + store.delete(key) + dispose(key, entry) + return entry.value + } + + const peek = (key: string) => { + sweep() + const entry = store.get(key) + if (!entry) return + if (!expired(entry)) return entry.value + store.delete(key) + dispose(key, entry) + } + + const get = (key: string) => { + sweep() + const entry = store.get(key) + if (entry && !expired(entry)) { + touch(key, entry) + return entry.value + } + if (entry) { + store.delete(key) + dispose(key, entry) + } + + const created = { + value: createValue(key), + touchedAt: now(), + } + store.set(key, created) + prune() + return created.value + } + + const clear = () => { + for (const [key, entry] of store) { + dispose(key, entry) + } + store.clear() + } + + return { + get, + peek, + delete: remove, + clear, + } +} diff --git a/packages/app/src/utils/server-health.test.ts b/packages/app/src/utils/server-health.test.ts new file mode 100644 index 0000000000..34c86685ae --- /dev/null +++ b/packages/app/src/utils/server-health.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test" +import { checkServerHealth } from "./server-health" + +describe("checkServerHealth", () => { + test("returns healthy response with version", async () => { + const fetch = (async () => + new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as unknown as typeof globalThis.fetch + + const result = await checkServerHealth("http://localhost:4096", fetch) + + expect(result).toEqual({ healthy: true, version: "1.2.3" }) + }) + + test("returns unhealthy when request fails", async () => { + const fetch = (async () => { + throw new Error("network") + }) as unknown as typeof globalThis.fetch + + const result = await checkServerHealth("http://localhost:4096", fetch) + + expect(result).toEqual({ healthy: false }) + }) + + test("uses provided abort signal", async () => { + let signal: AbortSignal | undefined + const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + signal = init?.signal ?? (input instanceof Request ? input.signal : undefined) + return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + }) as unknown as typeof globalThis.fetch + + const abort = new AbortController() + await checkServerHealth("http://localhost:4096", fetch, { signal: abort.signal }) + + expect(signal).toBe(abort.signal) + }) +}) diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts new file mode 100644 index 0000000000..ab33460b2b --- /dev/null +++ b/packages/app/src/utils/server-health.ts @@ -0,0 +1,29 @@ +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" + +export type ServerHealth = { healthy: boolean; version?: string } + +interface CheckServerHealthOptions { + timeoutMs?: number + signal?: AbortSignal +} + +function timeoutSignal(timeoutMs: number) { + return (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(timeoutMs) +} + +export async function checkServerHealth( + url: string, + fetch: typeof globalThis.fetch, + opts?: CheckServerHealthOptions, +): Promise { + const signal = opts?.signal ?? timeoutSignal(opts?.timeoutMs ?? 3000) + const sdk = createOpencodeClient({ + baseUrl: url, + fetch, + signal, + }) + return sdk.global + .health() + .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version })) + .catch(() => ({ healthy: false })) +} diff --git a/packages/app/src/utils/speech.ts b/packages/app/src/utils/speech.ts index 201c1261bd..52fc46b693 100644 --- a/packages/app/src/utils/speech.ts +++ b/packages/app/src/utils/speech.ts @@ -1,5 +1,6 @@ import { onCleanup } from "solid-js" import { createStore } from "solid-js/store" +import { getSpeechRecognitionCtor } from "@/utils/runtime-adapters" // Minimal types to avoid relying on non-standard DOM typings type RecognitionResult = { @@ -56,9 +57,8 @@ export function createSpeechRecognition(opts?: { onFinal?: (text: string) => void onInterim?: (text: string) => void }) { - const hasSupport = - typeof window !== "undefined" && - Boolean((window as any).webkitSpeechRecognition || (window as any).SpeechRecognition) + const ctor = getSpeechRecognitionCtor(typeof window === "undefined" ? undefined : window) + const hasSupport = Boolean(ctor) const [store, setStore] = createStore({ isRecording: false, @@ -155,10 +155,8 @@ export function createSpeechRecognition(opts?: { }, COMMIT_DELAY) } - if (hasSupport) { - const Ctor: new () => Recognition = (window as any).webkitSpeechRecognition || (window as any).SpeechRecognition - - recognition = new Ctor() + if (ctor) { + recognition = new ctor() recognition.continuous = false recognition.interimResults = true recognition.lang = opts?.lang || (typeof navigator !== "undefined" ? navigator.language : "en-US") diff --git a/packages/app/src/utils/time.ts b/packages/app/src/utils/time.ts new file mode 100644 index 0000000000..ac709d86dd --- /dev/null +++ b/packages/app/src/utils/time.ts @@ -0,0 +1,14 @@ +export function getRelativeTime(dateString: string): string { + const date = new Date(dateString) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffSeconds = Math.floor(diffMs / 1000) + const diffMinutes = Math.floor(diffSeconds / 60) + const diffHours = Math.floor(diffMinutes / 60) + const diffDays = Math.floor(diffHours / 24) + + if (diffSeconds < 60) return "Just now" + if (diffMinutes < 60) return `${diffMinutes}m ago` + if (diffHours < 24) return `${diffHours}h ago` + return `${diffDays}d ago` +} diff --git a/packages/app/src/utils/worktree.test.ts b/packages/app/src/utils/worktree.test.ts new file mode 100644 index 0000000000..8161e7ad83 --- /dev/null +++ b/packages/app/src/utils/worktree.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test" +import { Worktree } from "./worktree" + +const dir = (name: string) => `/tmp/opencode-worktree-${name}-${crypto.randomUUID()}` + +describe("Worktree", () => { + test("normalizes trailing slashes", () => { + const key = dir("normalize") + Worktree.ready(`${key}/`) + + expect(Worktree.get(key)).toEqual({ status: "ready" }) + }) + + test("pending does not overwrite a terminal state", () => { + const key = dir("pending") + Worktree.failed(key, "boom") + Worktree.pending(key) + + expect(Worktree.get(key)).toEqual({ status: "failed", message: "boom" }) + }) + + test("wait resolves shared pending waiter when ready", async () => { + const key = dir("wait-ready") + Worktree.pending(key) + + const a = Worktree.wait(key) + const b = Worktree.wait(`${key}/`) + + expect(a).toBe(b) + + Worktree.ready(key) + + expect(await a).toEqual({ status: "ready" }) + expect(await b).toEqual({ status: "ready" }) + }) + + test("wait resolves with failure message", async () => { + const key = dir("wait-failed") + const waiting = Worktree.wait(key) + + Worktree.failed(key, "permission denied") + + expect(await waiting).toEqual({ status: "failed", message: "permission denied" }) + expect(await Worktree.wait(key)).toEqual({ status: "failed", message: "permission denied" }) + }) +}) diff --git a/packages/console/app/script/generate-sitemap.ts b/packages/console/app/script/generate-sitemap.ts index 6cbffcb851..bdce205b90 100755 --- a/packages/console/app/script/generate-sitemap.ts +++ b/packages/console/app/script/generate-sitemap.ts @@ -3,6 +3,7 @@ import { readdir, writeFile } from "fs/promises" import { join, dirname } from "path" import { fileURLToPath } from "url" import { config } from "../src/config.js" +import { LOCALES, route } from "../src/lib/language.js" const __dirname = dirname(fileURLToPath(import.meta.url)) const BASE_URL = config.baseUrl @@ -27,12 +28,14 @@ async function getMainRoutes(): Promise { { path: "/zen", priority: 0.8, changefreq: "weekly" }, ] - for (const route of staticRoutes) { - routes.push({ - url: `${BASE_URL}${route.path}`, - priority: route.priority, - changefreq: route.changefreq, - }) + for (const item of staticRoutes) { + for (const locale of LOCALES) { + routes.push({ + url: `${BASE_URL}${route(locale, item.path)}`, + priority: item.priority, + changefreq: item.changefreq, + }) + } } return routes @@ -50,11 +53,13 @@ async function getDocsRoutes(): Promise { const slug = file.replace(".mdx", "") const path = slug === "index" ? "/docs/" : `/docs/${slug}` - routes.push({ - url: `${BASE_URL}${path}`, - priority: slug === "index" ? 0.9 : 0.7, - changefreq: "weekly", - }) + for (const locale of LOCALES) { + routes.push({ + url: `${BASE_URL}${route(locale, path)}`, + priority: slug === "index" ? 0.9 : 0.7, + changefreq: "weekly", + }) + } } } catch (error) { console.error("Error reading docs directory:", error) diff --git a/packages/console/app/src/app.tsx b/packages/console/app/src/app.tsx index cde2f01876..3eb70606a4 100644 --- a/packages/console/app/src/app.tsx +++ b/packages/console/app/src/app.tsx @@ -6,19 +6,27 @@ import { Favicon } from "@opencode-ai/ui/favicon" import { Font } from "@opencode-ai/ui/font" import "@ibm/plex/css/ibm-plex.css" import "./app.css" +import { LanguageProvider } from "~/context/language" +import { I18nProvider } from "~/context/i18n" +import { strip } from "~/lib/language" export default function App() { return ( ( - - opencode - - - - {props.children} - + + + + opencode + + + + {props.children} + + + )} > diff --git a/packages/console/app/src/component/email-signup.tsx b/packages/console/app/src/component/email-signup.tsx index 65f81b5fc6..bd33e92006 100644 --- a/packages/console/app/src/component/email-signup.tsx +++ b/packages/console/app/src/component/email-signup.tsx @@ -2,6 +2,7 @@ import { action, useSubmission } from "@solidjs/router" import dock from "../asset/lander/dock.png" import { Resource } from "@opencode-ai/console-resource" import { Show } from "solid-js" +import { useI18n } from "~/context/i18n" const emailSignup = action(async (formData: FormData) => { "use server" @@ -23,22 +24,21 @@ const emailSignup = action(async (formData: FormData) => { export function EmailSignup() { const submission = useSubmission(emailSignup) + const i18n = useI18n() return (
-

Be the first to know when we release new products

-

Join the waitlist for early access.

+

{i18n.t("email.title")}

+

{i18n.t("email.subtitle")}

- + -
- Almost done, check your inbox and confirm your email address -
+
{i18n.t("email.success")}
{submission.error}
diff --git a/packages/console/app/src/component/footer.tsx b/packages/console/app/src/component/footer.tsx index 27f8ddd65f..d81bf32476 100644 --- a/packages/console/app/src/component/footer.tsx +++ b/packages/console/app/src/component/footer.tsx @@ -2,12 +2,16 @@ import { createAsync } from "@solidjs/router" import { createMemo } from "solid-js" import { github } from "~/lib/github" import { config } from "~/config" +import { useLanguage } from "~/context/language" +import { useI18n } from "~/context/i18n" export function Footer() { + const language = useLanguage() + const i18n = useI18n() const githubData = createAsync(() => github()) const starCount = createMemo(() => githubData()?.stars - ? new Intl.NumberFormat("en-US", { + ? new Intl.NumberFormat(language.tag(language.locale()), { notation: "compact", compactDisplay: "short", }).format(githubData()!.stars!) @@ -18,20 +22,20 @@ export function Footer() { ) diff --git a/packages/console/app/src/component/header.tsx b/packages/console/app/src/component/header.tsx index 72e9d04189..6fa0f43ad8 100644 --- a/packages/console/app/src/component/header.tsx +++ b/packages/console/app/src/component/header.tsx @@ -19,6 +19,8 @@ import { createStore } from "solid-js/store" import { github } from "~/lib/github" import { createEffect, onCleanup } from "solid-js" import { config } from "~/config" +import { useI18n } from "~/context/i18n" +import { useLanguage } from "~/context/language" import "./header-context-menu.css" const isDarkMode = () => window.matchMedia("(prefers-color-scheme: dark)").matches @@ -36,12 +38,15 @@ const fetchSvgContent = async (svgPath: string): Promise => { export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) { const navigate = useNavigate() + const i18n = useI18n() + const language = useLanguage() const githubData = createAsync(() => github()) const starCount = createMemo(() => githubData()?.stars ? new Intl.NumberFormat("en-US", { notation: "compact", compactDisplay: "short", + maximumFractionDigits: 0, }).format(githubData()?.stars!) : config.github.starsFormatted.compact, ) @@ -118,9 +123,9 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) { return (
@@ -130,49 +135,56 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) { style={`left: ${store.contextMenuPosition.x}px; top: ${store.contextMenuPosition.y}px;`} > -