diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 52eec90991fe..9501a1be6516 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -2,4 +2,4 @@ blank_issues_enabled: false contact_links: - name: 💬 Discord Community url: https://discord.gg/opencode - about: For quick questions or real-time discussion. Note that issues are searchable and help others with the same question. + about: For support, troubleshooting, how-to questions, and real-time discussion. diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml deleted file mode 100644 index 8930ba693cc8..000000000000 --- a/.github/ISSUE_TEMPLATE/question.yml +++ /dev/null @@ -1,10 +0,0 @@ -name: Question -description: Ask a question -body: - - type: textarea - id: question - attributes: - label: Question - description: What's your question? - validations: - required: true diff --git a/.github/TEAM_MEMBERS b/.github/TEAM_MEMBERS index f892eb95dbc4..5268ff59ddc8 100644 --- a/.github/TEAM_MEMBERS +++ b/.github/TEAM_MEMBERS @@ -1,4 +1,5 @@ adamdotdevin +arvsrn Brendonovich fwang Hona @@ -7,11 +8,15 @@ jayair jlongster kitlangton kommander +ludvigrask MrMushrooooom +neriousy nexxeln R44VC0RP rekram1-node thdxr simonklee +Slickstef11 +usrnk1 vimtor -starptech +StarpTech diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index 5b44517ec511..9d29724d86a4 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -8,6 +8,13 @@ inputs: runs: using: "composite" steps: + # node-gyp@latest (invoked via bunx for native install scripts) requires Node >=22; + # some runner images ship an older system Node on PATH + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "24" + - name: Get baseline download URL id: bun-url shell: bash diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml deleted file mode 100644 index e93d5fbdb260..000000000000 --- a/.github/workflows/beta.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: beta - -on: - workflow_dispatch: - schedule: - - cron: "0 * * * *" - -jobs: - sync: - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: write - pull-requests: write - steps: - - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - fetch-depth: 0 - - - name: Setup Bun - uses: ./.github/actions/setup-bun - - - name: Setup Git Committer - id: setup-git-committer - uses: ./.github/actions/setup-git-committer - with: - opencode-app-id: ${{ vars.OPENCODE_APP_ID }} - opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - - name: Install OpenCode - run: bun i -g opencode-ai - - - name: Sync beta branch - env: - GH_TOKEN: ${{ steps.setup-git-committer.outputs.token }} - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - run: bun script/beta.ts diff --git a/.github/workflows/compliance-close.yml b/.github/workflows/compliance-close.yml index 14e68701e57d..a83824e5cf60 100644 --- a/.github/workflows/compliance-close.yml +++ b/.github/workflows/compliance-close.yml @@ -34,10 +34,48 @@ jobs: const now = Date.now(); const twoHours = 2 * 60 * 60 * 1000; + const orgMemberAssociations = new Set(['OWNER', 'MEMBER']); + const agentLogin = 'opencode-agent[bot]'; + const { data: file } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: '.github/TEAM_MEMBERS', + ref: 'dev', + }); + const teamMembers = new Set( + Buffer.from(file.content, 'base64') + .toString() + .split('\n') + .map((line) => line.trim().toLowerCase()) + .filter(Boolean) + ); + + function isExempt(item) { + const login = item.user?.login?.toLowerCase(); + return ( + login === agentLogin || + orgMemberAssociations.has(item.author_association) || + (login && teamMembers.has(login)) + ); + } for (const item of items) { const isPR = !!item.pull_request; const kind = isPR ? 'PR' : 'issue'; + const login = item.user?.login; + + if (isExempt(item)) { + core.info(`Skipping ${kind} #${item.number}; author ${login || 'unknown'} is exempt`); + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: item.number, + name: 'needs:compliance', + }); + } catch (e) {} + continue; + } const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 18e6cf7acb44..ef977a93bd2d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -35,6 +35,7 @@ jobs: - run: bun sst deploy --stage=${{ github.ref_name }} env: + GITHUB_TOKEN: ${{ github.token }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }} PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }} diff --git a/.github/workflows/duplicate-issues.yml b/.github/workflows/duplicate-issues.yml index 4648a2d0c3d3..3972247dafc6 100644 --- a/.github/workflows/duplicate-issues.yml +++ b/.github/workflows/duplicate-issues.yml @@ -17,12 +17,31 @@ jobs: with: fetch-depth: 1 + - name: Check exempt issue author + id: author + run: | + LOGIN="${{ github.event.issue.user.login }}" + ASSOCIATION="${{ github.event.issue.author_association }}" + + if [ "$LOGIN" = "opencode-agent[bot]" ] || + [ "$ASSOCIATION" = "OWNER" ] || + [ "$ASSOCIATION" = "MEMBER" ] || + grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - uses: ./.github/actions/setup-bun + if: steps.author.outputs.skip != 'true' - name: Install opencode + if: steps.author.outputs.skip != 'true' run: curl -fsSL https://opencode.ai/install | bash - name: Check duplicates and compliance + if: steps.author.outputs.skip != 'true' env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -38,6 +57,7 @@ jobs: opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created: Issue number: ${{ github.event.issue.number }} + Issue author association: ${{ github.event.issue.author_association }} Lookup this issue with gh issue view ${{ github.event.issue.number }}. @@ -49,6 +69,8 @@ jobs: Check whether the issue follows our contributing guidelines and issue templates. + If the issue author association is OWNER or MEMBER, skip this compliance check. Do not add the needs:compliance label for organization-owned issues. + This project has three issue templates that every issue MUST use one of: 1. Bug Report - requires a Description field with real content @@ -83,7 +105,7 @@ jobs: Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows: - If the issue is NOT compliant, start the comment with: + If the issue is NOT compliant and the author association is not OWNER or MEMBER, start the comment with: Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance @@ -129,12 +151,31 @@ jobs: with: fetch-depth: 1 + - name: Check exempt issue author + id: author + run: | + LOGIN="${{ github.event.issue.user.login }}" + ASSOCIATION="${{ github.event.issue.author_association }}" + + if [ "$LOGIN" = "opencode-agent[bot]" ] || + [ "$ASSOCIATION" = "OWNER" ] || + [ "$ASSOCIATION" = "MEMBER" ] || + grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - uses: ./.github/actions/setup-bun + if: steps.author.outputs.skip != 'true' - name: Install opencode + if: steps.author.outputs.skip != 'true' run: curl -fsSL https://opencode.ai/install | bash - name: Recheck compliance + if: steps.author.outputs.skip != 'true' env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -148,9 +189,12 @@ jobs: } run: | opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited. + Issue author association: ${{ github.event.issue.author_association }} Lookup this issue with gh issue view ${{ github.event.issue.number }}. + If the issue author association is OWNER or MEMBER, remove the needs:compliance label if present, delete the previous compliance comment if present, and do not post a new comment. + Re-check whether the issue now follows our contributing guidelines and issue templates. This project has three issue templates that every issue MUST use one of: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 29b5aa30476a..c86a1fda1353 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -219,7 +219,6 @@ jobs: build-electron: needs: - - build-cli - version if: github.repository == 'anomalyco/opencode' continue-on-error: false @@ -316,15 +315,14 @@ jobs: env: OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} - OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }} RUST_TARGET: ${{ matrix.settings.target }} - GH_TOKEN: ${{ github.token }} - GITHUB_RUN_ID: ${{ github.run_id }} - name: Build run: bun run build working-directory: packages/desktop env: + NODE_OPTIONS: --max-old-space-size=4096 + OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ vars.SENTRY_ORG }} @@ -342,8 +340,7 @@ jobs: env: OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} GH_TOKEN: ${{ steps.committer.outputs.token }} - CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }} - CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + CSC_KEYCHAIN: build.keychain APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8 APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index 1e652104d690..be2e099d0ed9 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -9,6 +9,7 @@ on: - "bun.lock" - "packages/storybook/**" - "packages/ui/**" + - "packages/session-ui/**" pull_request: branches: [dev] paths: @@ -17,6 +18,7 @@ on: - "bun.lock" - "packages/storybook/**" - "packages/ui/**" + - "packages/session-ui/**" workflow_dispatch: concurrency: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7498a84ae912..c69de1d93b0d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,35 +65,20 @@ jobs: - name: Run unit tests timeout-minutes: 20 - run: bun turbo test:ci --log-order=stream --log-prefix=task + run: GITHUB_ACTIONS=false bun turbo test env: OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} + - name: Check generated client + if: runner.os == 'Linux' + working-directory: packages/client + run: bun run check:generated + - name: Run HttpApi exerciser gates if: runner.os == 'Linux' working-directory: packages/opencode run: bun run test:httpapi - - name: Publish unit reports - if: always() - uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0 - with: - report_paths: packages/*/.artifacts/unit/junit.xml - check_name: "unit results (${{ matrix.settings.name }})" - detailed_summary: true - include_time_in_summary: true - fail_on_failure: false - - - name: Upload unit artifacts - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }} - include-hidden-files: true - if-no-files-found: ignore - retention-days: 7 - path: packages/*/.artifacts/unit/junit.xml - e2e: name: e2e (${{ matrix.settings.name }}) strategy: @@ -119,7 +104,8 @@ jobs: - name: Setup Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: "24" + # Playwright 1.59 hangs while extracting Chromium with Node 24.16. + node-version: "24.15" - name: Setup Bun uses: ./.github/actions/setup-bun @@ -151,7 +137,6 @@ jobs: run: bun --cwd packages/app test:e2e:local env: CI: true - PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml timeout-minutes: 30 - name: Upload Playwright artifacts @@ -162,6 +147,5 @@ jobs: if-no-files-found: ignore retention-days: 7 path: | - packages/app/e2e/junit-*.xml packages/app/e2e/test-results packages/app/e2e/playwright-report diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml index 27852a12ce4d..0350e43877c4 100644 --- a/.github/workflows/triage.yml +++ b/.github/workflows/triage.yml @@ -16,13 +16,32 @@ jobs: with: fetch-depth: 1 + - name: Check exempt issue author + id: author + run: | + LOGIN="${{ github.event.issue.user.login }}" + ASSOCIATION="${{ github.event.issue.author_association }}" + + if [ "$LOGIN" = "opencode-agent[bot]" ] || + [ "$ASSOCIATION" = "OWNER" ] || + [ "$ASSOCIATION" = "MEMBER" ] || + grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - name: Setup Bun + if: steps.author.outputs.skip != 'true' uses: ./.github/actions/setup-bun - name: Install opencode + if: steps.author.outputs.skip != 'true' run: curl -fsSL https://opencode.ai/install | bash - name: Triage issue + if: steps.author.outputs.skip != 'true' env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.opencode/agent/triage.md b/.opencode/agent/triage.md index 03df339cb89a..11c4c816cfae 100644 --- a/.opencode/agent/triage.md +++ b/.opencode/agent/triage.md @@ -1,7 +1,7 @@ --- mode: primary hidden: true -model: opencode/gpt-5.4-nano +model: opencode/gpt-5.4-mini color: "#44BA81" tools: "*": false diff --git a/.opencode/command/translate.md b/.opencode/command/translate.md index 8d493f4a8128..de18ae2ee86c 100644 --- a/.opencode/command/translate.md +++ b/.opencode/command/translate.md @@ -1,6 +1,6 @@ --- description: translate English to other languages -model: opencode/claude-opus-4-8 +model: opencode/gpt-5.6-sol --- run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time. diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 7f07577f8c2f..b0f7d59447db 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -2,8 +2,15 @@ "$schema": "https://opencode.ai/config.json", "provider": {}, "permission": {}, - "reference": { - "effect": "github.com/Effect-TS/effect-smol", + "references": { + "effect": { + "repository": "github.com/Effect-TS/effect-smol", + "description": "Use for Effect v4 and effect-smol implementation details", + }, + "opencode-local": { + "path": "~/.local/share/opencode", + "description": "Contains opencode logs and data", + }, }, "mcp": {}, "tools": { diff --git a/.opencode/skills/rtl-aware-development/SKILL.md b/.opencode/skills/rtl-aware-development/SKILL.md new file mode 100644 index 000000000000..595c981ead81 --- /dev/null +++ b/.opencode/skills/rtl-aware-development/SKILL.md @@ -0,0 +1,63 @@ +--- +name: rtl-aware-development +description: OpenCode Desktop should be RTL-aware. Use when implementing or reviewing RTL/LTR behavior in the web app, desktop app, CSS, menus, scrolling, resizing, icons, mixed-direction text, or Electron title bars. +--- + +# RTL-Aware Development + +Treat direction as independent from language. Test English in both directions as well as real RTL and mixed-script content. + +## Guidelines + +- Set `lang` and `dir` on the document, and propagate direction through component providers used by portaled menus and popovers. Do not change the selected locale merely to force RTL. +- Keep DOM and focus order semantic. Flexbox and Grid already follow `dir`; do not add `row-reverse`, CSS `order`, or reversed markup just to mirror a layout. +- Prefer logical CSS for semantic layout. Reserve physical coordinates for pointer positions, canvas geometry, native window controls, and other genuinely physical placement. + +```css +/* Avoid */ +padding-left: 12px; +right: 0; +border-right: 1px solid; +text-align: left; + +/* Prefer */ +padding-inline-start: 12px; +inset-inline-end: 0; +border-inline-end: 1px solid; +text-align: start; +``` + +- Isolate mixed-direction text. Use `dir="auto"` or `` for unknown text; keep code, URLs, IDs, and filesystem paths LTR without forcing the surrounding component LTR. + +```html +README.md C:\src\app.ts +``` + +- Mirror directional meaning, not every image. Back/forward, previous/next, disclosure, indentation, and directional progress may need mirroring. Do not mirror brands, clocks, media controls, charts, or text. Reverse physical gradients, `translateX`, SVG transforms, and animation deltas explicitly. +- Map interactions through direction. `clientX` remains physical; resizing a logical edge needs an RTL-aware delta. Logical previous/next keyboard controls may swap ArrowLeft/ArrowRight. Follow the relevant WAI-ARIA widget pattern. +- Do not assume LTR scrolling. RTL `scrollLeft` can start at `0` and become negative. Prefer `scrollIntoView({ inline: "nearest" })` or a tested direction-normalizing helper. +- For Electron title bars, prefer native caption controls and use `titleBarOverlay` plus `env(titlebar-area-*)` for the safe content rectangle. Keep Windows/macOS native-control avoidance and `trafficLightPosition` physical; keep app navigation inside that rectangle logical. Mark interactive titlebar children `app-region: no-drag`. +- Verify behavior, not screenshots alone. Check computed styles, pseudo-element geometry, hit zones, focus order, keyboard behavior, submenu direction, zoom/scaling, and both LTR and RTL scroll endpoints. + +## Test Matrix + +- English + LTR +- English + forced RTL +- A real RTL locale + RTL +- Mixed RTL/LTR content, long labels, numbers, code, and paths +- Keyboard, pointer resize, scrolling, menus/submenus, and Electron titlebar controls in both directions + +## References + +- [RTL Styling 101, Ahmad Shadeed](https://rtlstyling.com/posts/rtl-styling/) +- [CSS-Tricks: RTL Styling 101](https://css-tricks.com/rtl-styling-101/) +- [CSS-Tricks: CSS Logical Properties and Values](https://css-tricks.com/css-logical-properties-and-values/) +- [W3C: Structural markup and right-to-left text](https://www.w3.org/International/questions/qa-html-dir) +- [W3C: Inline bidirectional markup](https://www.w3.org/International/articles/inline-bidi-markup/) +- [MDN: CSS logical properties and values](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Logical_properties_and_values) +- [MDN: `dir`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/dir) +- [MDN: `scrollLeft`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollLeft) +- [web.dev: Logical properties](https://web.dev/learn/css/logical-properties/) +- [Electron: Custom title bar](https://www.electronjs.org/docs/latest/tutorial/custom-title-bar) +- [WAI-ARIA: Window splitter pattern](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/) +- [Kobalte: I18n Provider](https://kobalte.dev/docs/core/components/i18n-provider/) diff --git a/.opencode/tool/github-triage.ts b/.opencode/tool/github-triage.ts index f25ca48b384b..d610a81e497e 100644 --- a/.opencode/tool/github-triage.ts +++ b/.opencode/tool/github-triage.ts @@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin" const TEAM = { tui: ["kommander", "simonklee"], desktop_web: ["Hona", "Brendonovich"], - core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton", "starptech"], + core: ["jlongster", "rekram1-node", "neriousy", "nexxeln", "kitlangton"], inference: ["fwang", "MrMushrooooom", "starptech"], windows: ["Hona"], } as const diff --git a/.prettierignore b/.prettierignore index a2a277659691..7c72d70225ec 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,4 @@ sst-env.d.ts packages/desktop/src/bindings.ts +packages/client/src/generated/ +packages/client/src/generated-effect/ diff --git a/AGENTS.md b/AGENTS.md index c83966858657..cd2327e88811 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,15 @@ -- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`. +- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`. +- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly. +- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server. - The default branch in this repo is `dev`. - Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. +## Branch Names + +Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`. + +Examples: `session-recovery`, `fix-scroll-state`, `regenerate-sdk`. + ## Commits and PR Titles Use conventional commit-style messages and PR titles: `type(scope): summary`. @@ -22,6 +30,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi - Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity - Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream - In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module. +- In Effect generators, bind services to named variables before calling methods. Do not use nested service yields such as `yield* (yield* Foo.Service).bar()`. Reduce total variable count by inlining when a value is only used once. @@ -131,7 +140,7 @@ const table = sqliteTable("session", { ## Testing -- Avoid mocks as much as possible +- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option. - Test actual implementation, do not duplicate logic into tests - Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`. @@ -146,7 +155,7 @@ const table = sqliteTable("session", { - Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op. - Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics. - Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop. -- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash activity recovery requires a separate explicit design before it may retry provider work. -- Keep delivery vocabulary explicit. Prompts steer by default and coalesce into the active activity at the next safe provider-turn boundary. Explicit `queue` inputs open FIFO future activities one at a time after the active activity settles. +- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary. +- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once. - Keep EventV2 replay owner claims separate from clustered Session execution ownership. - Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned. diff --git a/CONTEXT.md b/CONTEXT.md index 6c94d90da6a2..5e5955d344a1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -24,7 +24,7 @@ A durable chronological instruction that tells the model the newly effective sta _Avoid_: System update, system notification, raw text diff **Context Epoch**: -The span during which one effective agent's initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition. +The span during which one initially rendered **System Context** remains the immutable provider-cache baseline, ending at completed compaction, Session movement, or an incompatible context transition that requires a fresh baseline. **Baseline System Context**: The full **System Context** rendered at the start of a **Context Epoch**. @@ -39,6 +39,18 @@ An expected temporary inability to observe a **Context Source** value; the runti **Safe Provider-Turn Boundary**: The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically. +**Admitted Prompt**: +A durable user input accepted into the Session inbox but not yet included in **Session History**. + +**Prompt Promotion**: +The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**. + +**Provider Turn**: +One request to a model provider and the response projected from that request. + +**Session Drain**: +One process-local execution span that promotes eligible input and runs required **Provider Turns** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary. + **Model Tool Output**: The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit. @@ -52,6 +64,27 @@ _Avoid_: Request body, wire options **Generation Controls**: Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog. +**Native Continuation Metadata**: +Opaque protocol-shaped data attached to assistant content and required to continue that content natively with a compatible model, such as a reasoning signature or provider-hosted item identifier. + +**PTY Environment**: +The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory. + +**OpenCode Client**: +The generated Promise and Effect APIs derived from the public `HttpApi`; **Embedded OpenCode** shares the Effect API through an in-memory `HttpClient` against the same router and handlers. +_Avoid_: Remote client + +**SDK Contract IR**: +The runtime-neutral compiled representation of the authoritative `HttpApi`, preserving encoded and decoded type projections plus transport metadata so independent SDK emitters can choose their public value model and runtime interpreter. + +**Embedded OpenCode**: +A scoped in-process host that structurally extends the **OpenCode Client**, supplies an in-memory HTTP transport, and exposes additional same-process capabilities directly. +_Avoid_: Local implementation + +**Page**: +A bounded ordered result containing `items` and opaque `previous` and `next` cursor links for navigating the same query in either direction. +_Avoid_: Response envelope + ## Relationships - A **System Context** is an opaque carrier composed from zero or more **Context Sources**. @@ -64,6 +97,11 @@ Provider-neutral sampling and output controls, partitioned from provider semanti - Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**. - Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes. - At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**. +- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**. +- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message. +- Steering prompts promote at the next **Safe Provider-Turn Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's provider-turn allowance; multiple prompts promoted at one boundary reset it once. +- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another. +- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, provider attempts, and tool state rather than inventing an enclosing execution identity. - The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline. - Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion. - Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history. @@ -72,33 +110,79 @@ Provider-neutral sampling and output controls, partitioned from provider semanti - Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed. - `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**. - `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked. -- `SystemContext.replace(...)` represents an explicit baseline-replacing transition such as compaction or model/provider switch; it either produces a fresh generation or reports that replacement is blocked by unavailable admitted context. -- Context Epoch preparation retries until stable after optimistic revision mismatches so concurrent replacement requests cannot terminate an otherwise valid safe-boundary run. +- `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable. - **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text. - Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**. - Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**. - Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location. - Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote. -- Context Epoch initialization is fenced against the authoritative Session Location, so an old-Location runner cannot recreate source context after a concurrent move. - Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values. - The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**. - Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam. - Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool. -- Switching the selected agent requests **Context Epoch** replacement. A switch admitted after the current **Safe Provider-Turn Boundary** applies to the next provider turn while leaving the already-prepared baseline durable. Epoch creation is fenced against the authoritative effective agent, and retries re-observe the current agent. -- A cross-agent replacement must complete before another provider turn; unavailable admitted context blocks that replacement instead of exposing the previous agent's privileged baseline. +- The selected agent and model are sampled when a provider turn starts. Changes admitted after that boundary apply to the next provider turn and do not restart the current turn. +- Selected-agent available-skill guidance remains a **Context Source**. An agent switch that changes that guidance produces a **Mid-Conversation System Message** while preserving the current baseline. - Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy. - Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily. - Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry. - **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them. - The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later. - A **Context Epoch** begins with one immutable **Baseline System Context**. -- A **Context Epoch** durably records the effective agent that owns its **Baseline System Context**. - A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**. - A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix. -- Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache. -- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history. +- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history. +- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn. +- **Native Continuation Metadata** remains in durable history. Provider-turn projection includes it only for a successful exact originating provider/model match; failed turns and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility. - **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding. - **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing. +- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`. +- Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs. +- The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately. +- Creating **Embedded OpenCode** is scoped. Closing its owning Scope releases the in-process server resources, database resources, registrations, and fibers. +- **Embedded OpenCode** exposes shared client capabilities and embedded-only capabilities on one object; consumers do not navigate through a nested `.client` property. +- The beta **OpenCode Client** currently uses plural consumer-facing capability groups such as `sessions`; whether the stable Session namespace should instead be singular `session` must be settled before stabilization. Internal server identifiers do not implicitly define public client names. +- Server's concrete `HttpApi` is authoritative for shared **OpenCode Client** capabilities. Codegen compiles its Session group directly; the Effect runtime uses an equivalent Protocol-only projection so generated artifacts remain independent of Core and Server. +- SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently. +- The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR. +- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against a Protocol-only client projection whose generated transport output is tested against Server's concrete API; the Promise emitter still derives zero-Effect structural wire types from the same IR. +- `@opencode-ai/protocol` owns Session endpoint construction and middleware placement. Server supplies concrete middleware keys to produce the authoritative build-time API; the client projection supplies transport-only keys without importing Core or Server at runtime. +- The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface. +- The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy. +- Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors. +- Promise-client infrastructure failures use one generated `ClientError` class with a structured reason such as transport failure, unexpected status, unsupported content type, or malformed response. Promise methods reject with either a tagged declared domain failure or `ClientError`, matching the Effect client's conceptual domain/infrastructure error division. +- Promise methods accept a separate optional per-call transport-options argument containing `AbortSignal` and header overrides. Cancellation and transport metadata do not enter the domain input object; broader interceptor and response-mode APIs remain deferred. +- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly. +- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction. +- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client. +- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides. +- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation. +- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently. +- Promise and Effect network clients ship from `@opencode-ai/client` behind isolated root and `/effect` exports. The root has no runtime path to Effect; `/effect` imports only Effect, Schema, and Protocol. +- The Effect-native scoped host belongs to `@opencode-ai/sdk-next`, which will assume the existing `@opencode-ai/sdk` name after legacy consumers migrate. Client remains network-only and SDK depends one-way on Client. +- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors. +- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names. +- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately. +- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes. +- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state. +- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior. +- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API. +- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed. +- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question. +- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented. +- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields. +- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor. +- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid. +- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary. +- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op. +- `sessions.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry. +- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate. +- **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata? +- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior. +- The public operation remains `sessions.prompt(...)`; `SessionInput.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics. +- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics. +- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session. +- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation. +- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter. - A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise. - When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply. - Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible. @@ -114,6 +198,23 @@ Provider-neutral sampling and output controls, partitioned from provider semanti - **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority. - Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads. +## Client contract architecture + +Semantic values that mean the same thing internally and publicly live in the lightweight Schema leaf. Core consumes Schema for domain behavior; Protocol composes Schema values into paths, payloads, envelopes, errors, cursors, and streams; Server imports both, hosts Protocol's exact groups, and owns protocol/domain adaptation. The root Promise client remains zero-Effect, `/effect` depends on Effect plus Schema and Protocol, and `@opencode-ai/sdk-next` composes the scoped in-process host above Client, Core, and Server. + +Shared public records are plain objects declared with `Schema.Struct`. A same-name inferred interface gives object records readable TypeScript signatures without constructors, prototypes, or nominal identity; unions retain explicit type aliases. + +Before stabilizing the client API: + +- Keep additional public schemas in Schema and additional network groups in Protocol; neither package may transitively load databases, Drizzle, Session execution, providers, watchers, native modules, or WASM. +- Keep concrete Location middleware keys in Server while Protocol owns their placement. Client projections may supply transport-only keys, but must prove generated equivalence with Server's concrete API. +- Project the existing list response envelope to the stable client **Page** shape and enforce separate initial-query and cursor-continuation inputs without changing the hosted V2 wire contract. +- Settle the stable consumer namespace (`session` versus the current beta `sessions`) and use an explicit codegen annotation if the consumer name should differ from the server group identifier. +- Preserve V2 route paths, operation IDs, codecs, errors, middleware behavior, and OpenAPI output while making this change. +- Preserve browser-safe `@opencode-ai/client` and `@opencode-ai/client/effect` bundles through import-boundary tests. +- Define embedded-host placement before supporting multiple hosts over one database. Hosts that share durable Session storage must also share process-local Session execution coordination, or each host must receive isolated storage explicitly. +- Keep an embedded request scope alive until any streamed response body finishes. The initial non-streaming Session surface does not exercise this lifetime boundary; Session and instance event streams must do so before joining the embedded client. + ## Example dialogue > **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e1a62ae9ca68..1ab14a7b628a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -252,33 +252,6 @@ These are not strictly enforced, they are just general guidelines: For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly. -## Trust & Vouch System - -This project uses [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. The vouch list is maintained in [`.github/VOUCHED.td`](.github/VOUCHED.td). - -### How it works - -- **Vouched users** are explicitly trusted contributors. -- **Denounced users** are explicitly blocked. Issues and pull requests from denounced users are automatically closed. If you have been denounced, you can request to be unvouched by reaching out to a maintainer on [Discord](https://opencode.ai/discord) -- **Everyone else** can participate normally — you don't need to be vouched to open issues or PRs. - -### For maintainers - -Collaborators with write access can manage the vouch list by commenting on any issue: - -- `vouch` — vouch for the issue author -- `vouch @username` — vouch for a specific user -- `denounce` — denounce the issue author -- `denounce @username` — denounce a specific user -- `denounce @username ` — denounce with a reason -- `unvouch` / `unvouch @username` — remove someone from the list - -Changes are committed automatically to `.github/VOUCHED.td`. - -### Denouncement policy - -Denouncement is reserved for users who repeatedly submit low-quality AI-generated contributions, spam, or otherwise act in bad faith. It is not used for disagreements or honest mistakes. - ## Issue Requirements All issues **must** use one of our issue templates: diff --git a/README.zh.md b/README.zh.md index 93f81e83cd0c..352e13ccaa1d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -125,4 +125,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换: --- -**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode) +**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=52ao9352-5623-4fa0-b7dd-3407c392c1af&qr_code=true) | [X.com](https://x.com/opencode) diff --git a/README.zht.md b/README.zht.md index 59d9709bd39f..f09358b1330b 100644 --- a/README.zht.md +++ b/README.zht.md @@ -125,4 +125,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。 --- -**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode) +**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=52ao9352-5623-4fa0-b7dd-3407c392c1af&qr_code=true) | [X.com](https://x.com/opencode) diff --git a/artifacts/glm52-rise-video/.gitignore b/artifacts/glm52-rise-video/.gitignore new file mode 100644 index 000000000000..9d38bb25911c --- /dev/null +++ b/artifacts/glm52-rise-video/.gitignore @@ -0,0 +1,3 @@ +node_modules +.remotion +out/frame-*.png diff --git a/artifacts/glm52-rise-video/bun.lock b/artifacts/glm52-rise-video/bun.lock new file mode 100644 index 000000000000..cac009ae812f --- /dev/null +++ b/artifacts/glm52-rise-video/bun.lock @@ -0,0 +1,483 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "glm52-rise-video", + "dependencies": { + "@remotion/cli": "^4.0.384", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "remotion": "^4.0.384", + }, + "devDependencies": { + "@types/react": "^19.2.8", + "@types/react-dom": "^19.2.3", + "typescript": "^5.8.2", + }, + }, + }, + "packages": { + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/parser": ["@babel/parser@7.24.1", "", { "bin": "./bin/babel-parser.js" }, "sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg=="], + + "@babel/types": ["@babel/types@7.24.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.23.4", "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" } }, "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w=="], + + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@mediabunny/aac-encoder": ["@mediabunny/aac-encoder@1.47.0", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-JNzgdJoHMFFnv5imi1+dmjZMudsJ1zNCUCEkKjBh90cqGhAFg8xu4V2gsyxE2i6oq/YpWx23P+OvoANLSMcDzA=="], + + "@mediabunny/flac-encoder": ["@mediabunny/flac-encoder@1.47.0", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-VpKmJO0xlYcFCRD6JvJlMbNQ6d/6YMHdO1gFIqWlZABHjSSL6BquNNEgWSCv5vdF8ELDBwIYBVZEslakIB/7GA=="], + + "@mediabunny/mp3-encoder": ["@mediabunny/mp3-encoder@1.47.0", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-JyzZyGeGRm2HVUQaGJ/VZT9OG+kG00mA8SLP/f3CO7+qWAmBncKc16WYXWHbZEo8Jn/ZGA6De32S5R2bTQbDqA=="], + + "@module-federation/error-codes": ["@module-federation/error-codes@0.22.0", "", {}, "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug=="], + + "@module-federation/runtime": ["@module-federation/runtime@0.22.0", "", { "dependencies": { "@module-federation/error-codes": "0.22.0", "@module-federation/runtime-core": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA=="], + + "@module-federation/runtime-core": ["@module-federation/runtime-core@0.22.0", "", { "dependencies": { "@module-federation/error-codes": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA=="], + + "@module-federation/runtime-tools": ["@module-federation/runtime-tools@0.22.0", "", { "dependencies": { "@module-federation/runtime": "0.22.0", "@module-federation/webpack-bundler-runtime": "0.22.0" } }, "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA=="], + + "@module-federation/sdk": ["@module-federation/sdk@0.22.0", "", {}, "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g=="], + + "@module-federation/webpack-bundler-runtime": ["@module-federation/webpack-bundler-runtime@0.22.0", "", { "dependencies": { "@module-federation/runtime": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="], + + "@remotion/bundler": ["@remotion/bundler@4.0.483", "", { "dependencies": { "@remotion/media-parser": "4.0.483", "@remotion/studio": "4.0.483", "@remotion/studio-shared": "4.0.483", "@remotion/timeline-utils": "4.0.483", "@rspack/core": "1.7.11", "@rspack/plugin-react-refresh": "1.6.1", "css-loader": "7.1.4", "esbuild": "0.28.1", "react-refresh": "0.18.0", "remotion": "4.0.483", "style-loader": "4.0.0", "webpack": "5.105.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-5fQRMYgr2sxZPBOThnIVuFwHBq/FhusJ+Ke6xopga8io8ecK/mj9GaRXC3tgfMa+YWuu8ZSXZ0U9EsMZjjP5YQ=="], + + "@remotion/canvas-capture": ["@remotion/canvas-capture@4.0.483", "", { "dependencies": { "mediabunny": "1.47.0", "remotion": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JABgbfvXwjFp1E61tlDN9gVAH8OoeF4kUUELNwG9kcNOE4ex8+vCYEO6VKIaXZ4l8A1PEAQ5EK//NheTJ80gfw=="], + + "@remotion/cli": ["@remotion/cli@4.0.483", "", { "dependencies": { "@remotion/bundler": "4.0.483", "@remotion/media-utils": "4.0.483", "@remotion/player": "4.0.483", "@remotion/renderer": "4.0.483", "@remotion/studio": "4.0.483", "@remotion/studio-server": "4.0.483", "@remotion/studio-shared": "4.0.483", "dotenv": "17.3.1", "minimist": "1.2.6", "prompts": "2.4.2", "remotion": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" }, "bin": { "remotion": "remotion-cli.js", "remotionb": "remotionb-cli.js", "remotiond": "remotiond-cli.js" } }, "sha512-z4TQcgTfQbSIV4GQpCvIihDL77M+leQelbxv962PmfSKTT7z3XnNN732PcZ4sei2+Xg+d2SD95K4tI5XBYs5Gw=="], + + "@remotion/compositor-darwin-arm64": ["@remotion/compositor-darwin-arm64@4.0.483", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CkEJXouGEoCs5PX3KOufkXrlT/Kgn0ZF7SEQ4meCYGYMZD+QPVtsmS1MXH8pQ4RHlFO6Tk7g0CIHMT3Y4yzpuA=="], + + "@remotion/compositor-darwin-x64": ["@remotion/compositor-darwin-x64@4.0.483", "", { "os": "darwin", "cpu": "x64" }, "sha512-vDZK5U8FYbHGMjFC2lUgJRBz8KXwbwFSvEQi8FATD0roNg75SoaEJWrqAOIVv8v5eBjPvo18znDc36NLRYn1Eg=="], + + "@remotion/compositor-linux-arm64-gnu": ["@remotion/compositor-linux-arm64-gnu@4.0.483", "", { "os": "linux", "cpu": "arm64" }, "sha512-1LzfYDY/DQxy3MIc9pCbTn1ObcBDcyI6MzODOwe1bTZl8TZxdUUt1M3hDrQ60AB6iHwruBAnzT9dnogiMIdSew=="], + + "@remotion/compositor-linux-arm64-musl": ["@remotion/compositor-linux-arm64-musl@4.0.483", "", { "os": "linux", "cpu": "arm64" }, "sha512-nG1btxg0HXuLzulIqAg9NnxWyewZNrA0iLe82HdniaJjeWLORreGo4cEEJz1SjbDpQuPEY562XGkTO2DLKK5mA=="], + + "@remotion/compositor-linux-x64-gnu": ["@remotion/compositor-linux-x64-gnu@4.0.483", "", { "os": "linux", "cpu": "x64" }, "sha512-CV4h6r2rNYh4P8utUzP6RM2vW2vq43nA3IvVOwRSuh9LwJh/7rwG53Oco4HanY/sstaNuymGpT57wdXwONU5CQ=="], + + "@remotion/compositor-linux-x64-musl": ["@remotion/compositor-linux-x64-musl@4.0.483", "", { "os": "linux", "cpu": "x64" }, "sha512-moKvavbkoz5nteJZA4K6LMgTTlewhwIbtO7DYARZ3LK3ClGUKnYfFcmaPS15hMun02JPTOMIWbrlaszdR75mxA=="], + + "@remotion/compositor-win32-x64-msvc": ["@remotion/compositor-win32-x64-msvc@4.0.483", "", { "os": "win32", "cpu": "x64" }, "sha512-eDpWe6Vy7ySHugwxlgaeohqdBwR1etxiymXzT13cgbPw2+p6d55z1tJ7VPTXyfu0o+UdyRTauWqD4msoxN0wow=="], + + "@remotion/licensing": ["@remotion/licensing@4.0.483", "", {}, "sha512-GRtjykrxMmB5Cjpq1oVqeYIRXI95vid6afX0LTLfp1shD6MR4lTZRoRuopxrrZp1E4XFGXoY64b6dlIudgTyBQ=="], + + "@remotion/media-parser": ["@remotion/media-parser@4.0.483", "", {}, "sha512-iva9Eof7QQX+3hGxH/N8pGi80wgdGuzOr825Zn7IuEe7IWdtRYWlFpCEghmyfhrwgaWa7RY51wg77ycR3c3nRg=="], + + "@remotion/media-utils": ["@remotion/media-utils@4.0.483", "", { "dependencies": { "mediabunny": "1.47.0", "remotion": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4T4WQh/U95kCBzgmz/EL/HL8Zeox/AKAL5ufsehrZFgwlcvH1EsKNJH/RWaAtNPRLF+pskEOgFzPYaczHeI8hg=="], + + "@remotion/player": ["@remotion/player@4.0.483", "", { "dependencies": { "remotion": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-yyIQ9vinUqqS0fQeL52bPCWQIr8qDXCqQ4iys8P7dSZncdu8V29p4W2a0/4eKV8bv5+mjnJQ/U6nl9NxBF1hdA=="], + + "@remotion/renderer": ["@remotion/renderer@4.0.483", "", { "dependencies": { "@remotion/licensing": "4.0.483", "@remotion/streaming": "4.0.483", "execa": "5.1.1", "remotion": "4.0.483", "source-map": "0.8.0-beta.0", "ws": "8.21.0" }, "optionalDependencies": { "@remotion/compositor-darwin-arm64": "4.0.483", "@remotion/compositor-darwin-x64": "4.0.483", "@remotion/compositor-linux-arm64-gnu": "4.0.483", "@remotion/compositor-linux-arm64-musl": "4.0.483", "@remotion/compositor-linux-x64-gnu": "4.0.483", "@remotion/compositor-linux-x64-musl": "4.0.483", "@remotion/compositor-win32-x64-msvc": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-eSKdQqm8rcl+GNyOhFsBgcPYWMcxTJWHL5xaNrd037piCaL3sdJ57OEO/AbfEvvOO14zwUvlsONKjlkLlEVyKg=="], + + "@remotion/streaming": ["@remotion/streaming@4.0.483", "", {}, "sha512-96rqrk+l5AfriHOqmqxMn8rI6UJfFY7r5UMDZoSKqXWzTOwj+0VWHTRl9ilZQMXhYACaMSA/5yrL9jgQEK8Yuw=="], + + "@remotion/studio": ["@remotion/studio@4.0.483", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@remotion/canvas-capture": "4.0.483", "@remotion/media-utils": "4.0.483", "@remotion/player": "4.0.483", "@remotion/renderer": "4.0.483", "@remotion/studio-shared": "4.0.483", "@remotion/timeline-utils": "4.0.483", "@remotion/web-renderer": "4.0.483", "@remotion/zod-types": "4.0.483", "mediabunny": "1.47.0", "memfs": "3.4.3", "open": "8.4.2", "remotion": "4.0.483", "semver": "7.5.3", "zod": "4.3.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-gNa1Lw8NmHl0jwFb5fk7rzA+WFTJQpGDT/49A38t9jpEwoF+h1mKTgY8gA202apzjBHrEECdFE450npnBOUpUw=="], + + "@remotion/studio-server": ["@remotion/studio-server@4.0.483", "", { "dependencies": { "@babel/parser": "7.24.1", "@babel/types": "7.24.0", "@remotion/bundler": "4.0.483", "@remotion/renderer": "4.0.483", "@remotion/studio-shared": "4.0.483", "memfs": "3.4.3", "open": "8.4.2", "prettier": "3.8.1", "recast": "0.23.11", "remotion": "4.0.483", "semver": "7.5.3" } }, "sha512-2FSLbVN5N8bgtQPX+RQRbis557tJJnVTOZuEBZndfC9NOoPdARlSgEnwVer9tBNczUlc6B3KrPxyPSlS3UjL+Q=="], + + "@remotion/studio-shared": ["@remotion/studio-shared@4.0.483", "", { "dependencies": { "remotion": "4.0.483" } }, "sha512-aXxBYUgsWgphHVq7T7bz+ICMGzfii7pcX1IxS5hbLMLJHqlDtk9g+qcxm4kJ2Fjj/Gb5gERKiNzYym8WZTIM2Q=="], + + "@remotion/timeline-utils": ["@remotion/timeline-utils@4.0.483", "", { "dependencies": { "mediabunny": "1.47.0" } }, "sha512-KTTMdpNA5uRLX+iisAITO+V9cJBk17F8EWvuUnAEVmAFOQTbGIyPI9HNw5pZmL7SlzOJdFjAtksTjipeYGkEug=="], + + "@remotion/web-renderer": ["@remotion/web-renderer@4.0.483", "", { "dependencies": { "@mediabunny/aac-encoder": "1.47.0", "@mediabunny/flac-encoder": "1.47.0", "@mediabunny/mp3-encoder": "1.47.0", "@remotion/licensing": "4.0.483", "mediabunny": "1.47.0", "remotion": "4.0.483" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-14iURfVWA/hcF/rJvkid1ZR5nmnVhkTIf071FabCFi6kdViOSVBugSglJ8tUPvWVbWmxlXGDGQD9CSU18IATww=="], + + "@remotion/zod-types": ["@remotion/zod-types@4.0.483", "", { "dependencies": { "remotion": "4.0.483" }, "peerDependencies": { "zod": "4.3.6" } }, "sha512-KIVYIzFpZBDB0wxGGQu5tDBr4XOYRzvrPXjmo6O/FI5Xx7O8hgvJmBs9VDkEwonAOft86sg79hI19F1DTX1p4w=="], + + "@rspack/binding": ["@rspack/binding@1.7.11", "", { "optionalDependencies": { "@rspack/binding-darwin-arm64": "1.7.11", "@rspack/binding-darwin-x64": "1.7.11", "@rspack/binding-linux-arm64-gnu": "1.7.11", "@rspack/binding-linux-arm64-musl": "1.7.11", "@rspack/binding-linux-x64-gnu": "1.7.11", "@rspack/binding-linux-x64-musl": "1.7.11", "@rspack/binding-wasm32-wasi": "1.7.11", "@rspack/binding-win32-arm64-msvc": "1.7.11", "@rspack/binding-win32-ia32-msvc": "1.7.11", "@rspack/binding-win32-x64-msvc": "1.7.11" } }, "sha512-2MGdy2s2HimsDT444Bp5XnALzNRxuBNc7y0JzyuqKbHBywd4x2NeXyhWXXoxufaCFu5PBc9Qq9jyfjW2Aeh06Q=="], + + "@rspack/binding-darwin-arm64": ["@rspack/binding-darwin-arm64@1.7.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig=="], + + "@rspack/binding-darwin-x64": ["@rspack/binding-darwin-x64@1.7.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA=="], + + "@rspack/binding-linux-arm64-gnu": ["@rspack/binding-linux-arm64-gnu@1.7.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA=="], + + "@rspack/binding-linux-arm64-musl": ["@rspack/binding-linux-arm64-musl@1.7.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw=="], + + "@rspack/binding-linux-x64-gnu": ["@rspack/binding-linux-x64-gnu@1.7.11", "", { "os": "linux", "cpu": "x64" }, "sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ=="], + + "@rspack/binding-linux-x64-musl": ["@rspack/binding-linux-x64-musl@1.7.11", "", { "os": "linux", "cpu": "x64" }, "sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg=="], + + "@rspack/binding-wasm32-wasi": ["@rspack/binding-wasm32-wasi@1.7.11", "", { "dependencies": { "@napi-rs/wasm-runtime": "1.0.7" }, "cpu": "none" }, "sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ=="], + + "@rspack/binding-win32-arm64-msvc": ["@rspack/binding-win32-arm64-msvc@1.7.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA=="], + + "@rspack/binding-win32-ia32-msvc": ["@rspack/binding-win32-ia32-msvc@1.7.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw=="], + + "@rspack/binding-win32-x64-msvc": ["@rspack/binding-win32-x64-msvc@1.7.11", "", { "os": "win32", "cpu": "x64" }, "sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q=="], + + "@rspack/core": ["@rspack/core@1.7.11", "", { "dependencies": { "@module-federation/runtime-tools": "0.22.0", "@rspack/binding": "1.7.11", "@rspack/lite-tapable": "1.1.0" }, "peerDependencies": { "@swc/helpers": ">=0.5.1" }, "optionalPeers": ["@swc/helpers"] }, "sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew=="], + + "@rspack/lite-tapable": ["@rspack/lite-tapable@1.1.0", "", {}, "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw=="], + + "@rspack/plugin-react-refresh": ["@rspack/plugin-react-refresh@1.6.1", "", { "dependencies": { "error-stack-parser": "^2.1.4", "html-entities": "^2.6.0" }, "peerDependencies": { "react-refresh": ">=0.10.0 <1.0.0", "webpack-hot-middleware": "2.x" }, "optionalPeers": ["webpack-hot-middleware"] }, "sha512-eqqW5645VG3CzGzFgNg5HqNdHVXY+567PGjtDhhrM8t67caxmsSzRmT5qfoEIfBcGgFkH9vEg7kzXwmCYQdQDw=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + + "@types/dom-mediacapture-transform": ["@types/dom-mediacapture-transform@0.1.11", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ=="], + + "@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.13", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="], + + "@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="], + + "@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], + + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="], + + "@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="], + + "@webassemblyjs/helper-api-error": ["@webassemblyjs/helper-api-error@1.13.2", "", {}, "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ=="], + + "@webassemblyjs/helper-buffer": ["@webassemblyjs/helper-buffer@1.14.1", "", {}, "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA=="], + + "@webassemblyjs/helper-numbers": ["@webassemblyjs/helper-numbers@1.13.2", "", { "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA=="], + + "@webassemblyjs/helper-wasm-bytecode": ["@webassemblyjs/helper-wasm-bytecode@1.13.2", "", {}, "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA=="], + + "@webassemblyjs/helper-wasm-section": ["@webassemblyjs/helper-wasm-section@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/wasm-gen": "1.14.1" } }, "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw=="], + + "@webassemblyjs/ieee754": ["@webassemblyjs/ieee754@1.13.2", "", { "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw=="], + + "@webassemblyjs/leb128": ["@webassemblyjs/leb128@1.13.2", "", { "dependencies": { "@xtuc/long": "4.2.2" } }, "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw=="], + + "@webassemblyjs/utf8": ["@webassemblyjs/utf8@1.13.2", "", {}, "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ=="], + + "@webassemblyjs/wasm-edit": ["@webassemblyjs/wasm-edit@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/helper-wasm-section": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-opt": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1", "@webassemblyjs/wast-printer": "1.14.1" } }, "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ=="], + + "@webassemblyjs/wasm-gen": ["@webassemblyjs/wasm-gen@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg=="], + + "@webassemblyjs/wasm-opt": ["@webassemblyjs/wasm-opt@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1" } }, "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw=="], + + "@webassemblyjs/wasm-parser": ["@webassemblyjs/wasm-parser@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ=="], + + "@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="], + + "@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="], + + "@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="], + + "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + + "acorn-import-phases": ["acorn-import-phases@1.0.4", "", { "peerDependencies": { "acorn": "^8.14.0" } }, "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], + + "ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], + + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.40", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw=="], + + "browserslist": ["browserslist@4.28.4", "", { "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", "electron-to-chromium": "^1.5.376", "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="], + + "chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="], + + "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "css-loader": ["css-loader@7.1.4", "", { "dependencies": { "icss-utils": "^5.1.0", "postcss": "^8.4.40", "postcss-modules-extract-imports": "^3.1.0", "postcss-modules-local-by-default": "^4.0.5", "postcss-modules-scope": "^3.2.0", "postcss-modules-values": "^4.0.0", "postcss-value-parser": "^4.2.0", "semver": "^7.6.3" }, "peerDependencies": { "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", "webpack": "^5.27.0" }, "optionalPeers": ["@rspack/core", "webpack"] }, "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], + + "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.379", "", {}, "sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA=="], + + "enhanced-resolve": ["enhanced-resolve@5.24.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw=="], + + "error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="], + + "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], + + "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], + + "fs-monkey": ["fs-monkey@1.0.3", "", {}, "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q=="], + + "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + + "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="], + + "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], + + "icss-utils": ["icss-utils@5.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA=="], + + "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + + "loader-runner": ["loader-runner@4.3.2", "", {}, "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w=="], + + "lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="], + + "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + + "mediabunny": ["mediabunny@1.47.0", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-XQMZAcaKPkJ7hQ/Q2fvBdl3ZazQl2WVxDysUbJWh4PuAnLoerdsQBdPTDWdUdK6hh26LQ8Ue94MLLnmpWvlUYg=="], + + "memfs": ["memfs@3.4.3", "", { "dependencies": { "fs-monkey": "1.0.3" } }, "sha512-eivjfi7Ahr6eQTn44nvTnR60e4a1Fs1Via2kCR5lHo/kyNoiMWaXCNJ/GpSd0ilXas2JSOl9B5FTIhflXu0hlg=="], + + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + + "minimist": ["minimist@1.2.6", "", {}, "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="], + + "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + + "node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="], + + "npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "postcss-modules-extract-imports": ["postcss-modules-extract-imports@3.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q=="], + + "postcss-modules-local-by-default": ["postcss-modules-local-by-default@4.2.0", "", { "dependencies": { "icss-utils": "^5.0.0", "postcss-selector-parser": "^7.0.0", "postcss-value-parser": "^4.1.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw=="], + + "postcss-modules-scope": ["postcss-modules-scope@3.2.1", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA=="], + + "postcss-modules-values": ["postcss-modules-values@4.0.0", "", { "dependencies": { "icss-utils": "^5.0.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ=="], + + "postcss-selector-parser": ["postcss-selector-parser@7.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + + "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + + "remotion": ["remotion@4.0.483", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-lf4xq4Twn75TQeTavFLrTE4zdck7EKBSx07ZrPv4om40Sist+3G0kq7NNzSjMeJ6G2Wx6vI+0oICD/vv4sGWtg=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], + + "semver": ["semver@7.5.3", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="], + + "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + + "style-loader": ["style-loader@4.0.0", "", { "peerDependencies": { "webpack": "^5.27.0" } }, "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA=="], + + "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "terser": ["terser@5.48.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q=="], + + "terser-webpack-plugin": ["terser-webpack-plugin@5.6.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ=="], + + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + + "to-fast-properties": ["to-fast-properties@2.0.0", "", {}, "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog=="], + + "tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "watchpack": ["watchpack@2.5.2", "", { "dependencies": { "graceful-fs": "^4.1.2" } }, "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg=="], + + "webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="], + + "webpack": ["webpack@5.105.0", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.19.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.5.1", "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw=="], + + "webpack-sources": ["webpack-sources@3.5.0", "", {}, "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ=="], + + "whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "css-loader/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + } +} diff --git a/artifacts/glm52-rise-video/out/flash-share.mp4 b/artifacts/glm52-rise-video/out/flash-share.mp4 new file mode 100644 index 000000000000..3e182b4c1126 Binary files /dev/null and b/artifacts/glm52-rise-video/out/flash-share.mp4 differ diff --git a/artifacts/glm52-rise-video/out/glm-52-broke-out.mp4 b/artifacts/glm52-rise-video/out/glm-52-broke-out.mp4 new file mode 100644 index 000000000000..eb456d034540 Binary files /dev/null and b/artifacts/glm52-rise-video/out/glm-52-broke-out.mp4 differ diff --git a/artifacts/glm52-rise-video/out/june-totals.png b/artifacts/glm52-rise-video/out/june-totals.png new file mode 100644 index 000000000000..678376dbed69 Binary files /dev/null and b/artifacts/glm52-rise-video/out/june-totals.png differ diff --git a/artifacts/glm52-rise-video/out/minimax-climb.mp4 b/artifacts/glm52-rise-video/out/minimax-climb.mp4 new file mode 100644 index 000000000000..8ca6b678ae8c Binary files /dev/null and b/artifacts/glm52-rise-video/out/minimax-climb.mp4 differ diff --git a/artifacts/glm52-rise-video/out/novel-1984.mp4 b/artifacts/glm52-rise-video/out/novel-1984.mp4 new file mode 100644 index 000000000000..b94a8992578e Binary files /dev/null and b/artifacts/glm52-rise-video/out/novel-1984.mp4 differ diff --git a/artifacts/glm52-rise-video/out/nz-sheep.mp4 b/artifacts/glm52-rise-video/out/nz-sheep.mp4 new file mode 100644 index 000000000000..64d382da2659 Binary files /dev/null and b/artifacts/glm52-rise-video/out/nz-sheep.mp4 differ diff --git a/artifacts/glm52-rise-video/package.json b/artifacts/glm52-rise-video/package.json new file mode 100644 index 000000000000..fb08574eb222 --- /dev/null +++ b/artifacts/glm52-rise-video/package.json @@ -0,0 +1,24 @@ +{ + "name": "glm52-rise-video", + "private": true, + "type": "module", + "scripts": { + "render": "remotion render src/index.tsx GLM52Rise out/glm-52-broke-out.mp4 --codec h264 --pixel-format yuv420p", + "render:sheep": "remotion render src/index.tsx NZSheep out/nz-sheep.mp4 --codec h264 --pixel-format yuv420p", + "render:novel": "remotion render src/index.tsx NovelTokens out/novel-1984.mp4 --codec h264 --pixel-format yuv420p", + "render:flash": "remotion render src/index.tsx FlashShare out/flash-share.mp4 --codec h264 --pixel-format yuv420p", + "render:minimax": "remotion render src/index.tsx MiniMaxClimb out/minimax-climb.mp4 --codec h264 --pixel-format yuv420p", + "still:june": "remotion still src/index.tsx JuneTotals out/june-totals.png --frame=0" + }, + "dependencies": { + "@remotion/cli": "^4.0.384", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "remotion": "^4.0.384" + }, + "devDependencies": { + "@types/react": "^19.2.8", + "@types/react-dom": "^19.2.3", + "typescript": "^5.8.2" + } +} diff --git a/artifacts/glm52-rise-video/public/book.jpg b/artifacts/glm52-rise-video/public/book.jpg new file mode 100644 index 000000000000..662ca244cdaf Binary files /dev/null and b/artifacts/glm52-rise-video/public/book.jpg differ diff --git a/artifacts/glm52-rise-video/public/sheep.jpg b/artifacts/glm52-rise-video/public/sheep.jpg new file mode 100644 index 000000000000..d9ed232a89ae Binary files /dev/null and b/artifacts/glm52-rise-video/public/sheep.jpg differ diff --git a/artifacts/glm52-rise-video/src/data.ts b/artifacts/glm52-rise-video/src/data.ts new file mode 100644 index 000000000000..d9fab2a63114 --- /dev/null +++ b/artifacts/glm52-rise-video/src/data.ts @@ -0,0 +1,156 @@ +// Verified against the production opencode-stats PlanetScale DB. +// Scope: tier='Go' (OpenCode Go), dataset='zen', client='all', source='all', grain='day'. +// metric = total_tokens. Daily token volume per model; GLM-5.2 (zhipu) launched Jun 17. +// Segments per day (tokens): glm-5.2, deepseek-v4-flash, deepseek-v4-pro, minimax-m3, all others. + +export type Day = { + date: string + total: number + glm: number + dsf: number + dsp: number + mm: number + others: number +} + +export const days: Day[] = [ + { + date: "Jun 12", + total: 2_283_799_449_383, + glm: 0, + dsf: 1_176_701_653_509, + dsp: 569_527_034_307, + mm: 159_016_250_684, + others: 378_554_510_883, + }, + { + date: "Jun 13", + total: 2_008_462_388_420, + glm: 0, + dsf: 995_338_131_997, + dsp: 445_817_536_548, + mm: 211_743_241_967, + others: 355_563_477_908, + }, + { + date: "Jun 14", + total: 2_007_785_405_251, + glm: 0, + dsf: 983_954_176_228, + dsp: 428_151_999_341, + mm: 262_476_527_930, + others: 333_202_701_752, + }, + { + date: "Jun 15", + total: 2_694_736_103_062, + glm: 0, + dsf: 1_255_893_953_859, + dsp: 632_223_338_376, + mm: 352_507_442_991, + others: 454_111_367_836, + }, + { + date: "Jun 16", + total: 2_838_153_758_908, + glm: 0, + dsf: 1_336_625_283_800, + dsp: 676_480_415_730, + mm: 305_268_829_013, + others: 519_779_230_365, + }, + { + date: "Jun 17", + total: 2_778_964_711_109, + glm: 70_095_977_043, + dsf: 1_339_831_523_555, + dsp: 660_414_395_220, + mm: 251_302_096_157, + others: 457_320_719_134, + }, + { + date: "Jun 18", + total: 2_806_992_430_656, + glm: 201_130_231_172, + dsf: 1_295_599_996_869, + dsp: 595_665_008_776, + mm: 322_205_104_324, + others: 392_392_089_515, + }, + { + date: "Jun 19", + total: 2_419_611_630_232, + glm: 199_086_413_910, + dsf: 1_115_750_468_802, + dsp: 475_965_869_304, + mm: 303_586_698_735, + others: 325_222_179_481, + }, + { + date: "Jun 20", + total: 2_188_278_916_865, + glm: 193_931_516_396, + dsf: 1_050_194_681_012, + dsp: 395_303_435_278, + mm: 281_998_000_337, + others: 266_851_283_842, + }, + { + date: "Jun 21", + total: 2_042_309_961_344, + glm: 181_894_043_118, + dsf: 985_164_570_580, + dsp: 368_194_079_542, + mm: 259_812_551_324, + others: 247_244_716_780, + }, + { + date: "Jun 22", + total: 2_893_934_325_663, + glm: 301_759_048_475, + dsf: 1_298_124_282_989, + dsp: 581_012_596_194, + mm: 371_581_117_839, + others: 341_457_280_166, + }, + { + date: "Jun 23", + total: 3_109_009_321_480, + glm: 282_277_235_158, + dsf: 1_423_571_678_821, + dsp: 627_374_654_587, + mm: 429_416_300_508, + others: 346_369_452_406, + }, + { + date: "Jun 24", + total: 2_939_149_971_595, + glm: 256_497_442_533, + dsf: 1_373_583_023_234, + dsp: 601_270_997_775, + mm: 391_586_493_231, + others: 316_212_014_822, + }, + { + date: "Jun 25", + total: 3_029_641_552_948, + glm: 256_279_657_734, + dsf: 1_481_084_002_776, + dsp: 602_077_167_287, + mm: 375_985_302_874, + others: 314_215_422_277, + }, +] + +export const launchIndex = 5 // Jun 17, first day of GLM-5.2 usage +// GLM-5.2 weekly token volume, Jun 19-25 (sum of glm): 1,671,725,357,324 = 1.67T +export const glmWeekTokensT = 1.672 + +// stacked segments, bottom -> top. GLM-5.2 is the hero (blue); the rest are the field it cut into. +export const segments = [ + { key: "glm", label: "GLM-5.2", color: "#3b5cf6", hero: true }, + { key: "dsf", label: "deepseek-v4-flash", color: "#9ca3ad" }, + { key: "dsp", label: "deepseek-v4-pro", color: "#b3b9c1" }, + { key: "mm", label: "minimax-m3", color: "#c8cdd3" }, + { key: "others", label: "other models", color: "#dde0e4" }, +] as const diff --git a/artifacts/glm52-rise-video/src/flash.tsx b/artifacts/glm52-rise-video/src/flash.tsx new file mode 100644 index 000000000000..5d7aaef9bf5e --- /dev/null +++ b/artifacts/glm52-rise-video/src/flash.tsx @@ -0,0 +1,185 @@ +import React from "react" +import { AbsoluteFill, Easing, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion" + +// stats.opencode.ai design tokens (light theme) +const c = { + bg: "#ffffff", + ink: "#161616", + muted: "#5c5c5c", + faint: "#808080", + line: "#e6e6e6", + dot: "#e4e4e4", + gray: "#aab0b8", + accent: "#3b5cf6", + accentHi: "#5b78ff", +} +const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' + +const DOT_MASK = + "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E\")" + +// verified: OpenCode Go, week of Jun 22-28, 2026 (2026-W26). share = % of 19.64T total Go tokens. +const bars = [ + { label: "deepseek-v4-flash", share: 48.3, hero: true }, + { label: "deepseek-v4-pro", share: 19.4 }, + { label: "minimax-m3", share: 13.0 }, + { label: "glm-5.2", share: 8.3 }, + { label: "mimo-v2.5", share: 4.3 }, + { label: "kimi-k2.7-code", share: 2.6 }, + { label: "other models", share: 4.1 }, +] + +function DataWordmark({ height = 30 }: { height?: number }) { + return ( + + + + + + + + + ) +} + +export function FlashShare() { + const frame = useCurrentFrame() + const { fps } = useVideoConfig() + + const grow = (i: number) => + Math.min( + 1, + Math.max(0, spring({ frame: frame - 18 - i * 7, fps, config: { damping: 18, stiffness: 120, mass: 0.6 } })), + ) + + return ( + +
+ {/* header */} +
+ +
JUN 22–28, 2026
+
+ + {/* headline (static) */} +
+
+ OPENCODE GO · SHARE OF TOKENS +
+
+
DeepSeek V4 Flash
+
+ 48% +
+
+
+ + {/* bar chart */} +
+ {bars.map((b, i) => { + const g = grow(i) + const pct = b.share * g + return ( +
+
+ {b.label} +
+
+ {/* dotted 100% track — height is a multiple of the 12px tile, anchored bottom, so dots never clip */} +
+ {/* fill */} +
+
+
+ {Math.round(pct)}% +
+
+ ) + })} +
+ + {/* footer */} +
+
+ + DeepSeek V4 Flash · 9.48T tokens · 83.6M requests +
+
opencode.ai/data
+
+
+ + ) +} diff --git a/artifacts/glm52-rise-video/src/index.tsx b/artifacts/glm52-rise-video/src/index.tsx new file mode 100644 index 000000000000..3265151ac542 --- /dev/null +++ b/artifacts/glm52-rise-video/src/index.tsx @@ -0,0 +1,36 @@ +import { Composition, registerRoot } from "remotion" +import { GLM52Rise } from "./video" +import { NZSheep } from "./sheep" +import { NovelTokens } from "./novel" +import { FlashShare } from "./flash" +import { MiniMaxClimb } from "./minimax" +import { JuneTotals } from "./june" + +function Root() { + return ( + <> + + + + + + + + ) +} + +registerRoot(Root) diff --git a/artifacts/glm52-rise-video/src/june.tsx b/artifacts/glm52-rise-video/src/june.tsx new file mode 100644 index 000000000000..aa3f9de9bef4 --- /dev/null +++ b/artifacts/glm52-rise-video/src/june.tsx @@ -0,0 +1,144 @@ +import React from "react" +import { AbsoluteFill } from "remotion" + +const c = { + bg: "#ffffff", + ink: "#161616", + muted: "#5c5c5c", + faint: "#808080", + line: "#e6e6e6", + dot: "#dcdcdc", + accent: "#3b5cf6", +} +const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' + +const DOT_MASK = + "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E\")" + +// verified: OpenCode Go (tier=Go, dataset=zen), June 1-30, 2026. +// 72.78T tokens · 651.4M requests · 11.42M sessions -> rounded headline figures. + +function DataWordmark({ height = 30 }: { height?: number }) { + return ( + + + + + + + + + ) +} + +function DotBand() { + return ( +
+ ) +} + +function Metric({ value, label }: { value: string; label: string }) { + return ( +
+
+ {value} +
+
{label}
+
+ ) +} + +export function JuneTotals() { + return ( + +
+ {/* header */} +
+ +
MONTHLY RECAP
+
+ + + {/* hero */} +
+
OPENCODE GO · JUNE 2026
+
+
+ 73T +
+
tokens processed
+
+ + {/* supporting metrics */} +
+ + +
+
+ + {/* footer */} + +
+
opencode.ai/data
+
+
+
+ ) +} diff --git a/artifacts/glm52-rise-video/src/minimax.tsx b/artifacts/glm52-rise-video/src/minimax.tsx new file mode 100644 index 000000000000..44cf09a1e75a --- /dev/null +++ b/artifacts/glm52-rise-video/src/minimax.tsx @@ -0,0 +1,201 @@ +import React from "react" +import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion" + +const c = { + bg: "#ffffff", + ink: "#161616", + muted: "#5c5c5c", + faint: "#808080", + line: "#e6e6e6", + dot: "#e4e4e4", + accent: "#3b5cf6", + accentHi: "#5b78ff", + accentDim: "#aebcf3", +} +const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' + +const DOT_MASK = + "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E\")" + +// verified: minimax-m3, OpenCode Go (tier=Go, dataset=zen), weekly total_tokens. +// W26 2.559T is +23.2% / +482.3B vs W25 2.077T. +const weeks = [ + { label: "May 25", t: 0.008 }, + { label: "Jun 1", t: 0.429 }, + { label: "Jun 8", t: 1.192 }, + { label: "Jun 15", t: 2.077 }, + { label: "Jun 22", t: 2.559, latest: true }, +] +const AXIS_MAX = 3.0 + +function DataWordmark({ height = 30 }: { height?: number }) { + return ( + + + + + + + + + ) +} + +const CHART_H = 420 // multiple of 12 so the dotted tracks never clip + +export function MiniMaxClimb() { + const frame = useCurrentFrame() + const { fps } = useVideoConfig() + + const grow = (i: number) => + Math.min( + 1, + Math.max(0, spring({ frame: frame - 22 - i * 9, fps, config: { damping: 18, stiffness: 110, mass: 0.6 } })), + ) + + return ( + +
+ {/* header */} +
+ +
JUN 22–28, 2026
+
+ + {/* headline (static) */} +
+
+ OPENCODE GO · WEEKLY TOKENS +
+
+
MiniMax M3
+
+
+ +23.2% +
+
+ WEEK OVER WEEK +
+
+
+
+ + {/* column chart */} +
+
+ {weeks.map((w, i) => { + const g = grow(i) + const h = Math.round((w.t / AXIS_MAX) * CHART_H * g) + return ( +
+ {/* value + bar */} +
+ {/* dotted track */} +
+ {/* fill */} +
+ {/* value label */} +
+ {w.t.toFixed(2)}T +
+
+ {/* week label */} +
+ {w.label} +
+
+ ) + })} +
+
+ + {/* footer */} +
+
+ + 2.56T tokens last week · +482.3B added +
+
opencode.ai/data
+
+
+ + ) +} diff --git a/artifacts/glm52-rise-video/src/novel.tsx b/artifacts/glm52-rise-video/src/novel.tsx new file mode 100644 index 000000000000..9d649de4bbbb --- /dev/null +++ b/artifacts/glm52-rise-video/src/novel.tsx @@ -0,0 +1,135 @@ +import React from "react" +import { AbsoluteFill, Easing, Img, interpolate, staticFile, useCurrentFrame } from "remotion" + +const c = { + white: "#ffffff", + dim: "rgba(255,255,255,0.74)", +} +const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' + +// verified: OpenCode Go, week of Jun 22-28, 2026 (2026-W26) +// 19,642,742,937,105 tokens / 173,651,197 requests = 113,116 tokens/request +const AVG = 113116 +const K = Math.round(AVG / 1000) // 113 + +const nf = new Intl.NumberFormat("en-US") + +function DataWordmark({ height = 30 }: { height?: number }) { + return ( + + + + + + + + + ) +} + +export function NovelTokens() { + const frame = useCurrentFrame() + + const k = Math.round( + K * + interpolate(frame, [18, 92], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: Easing.out(Easing.cubic), + }), + ) + + const zoom = interpolate(frame, [0, 150], [1.06, 1.12], { + extrapolateRight: "clamp", + easing: Easing.inOut(Easing.quad), + }) + + return ( + + + + {/* legibility scrims */} +
+ +
+ {/* header */} +
+ +
JUN 22–28, 2026
+
+ + {/* bottom block */} +
+
+ OPENCODE GO · LAST WEEK +
+
+ {k}K +
+
tokens per request
+ +
+
{nf.format(AVG)} tokens / request · last week
+
opencode.ai/data
+
+
+
+ + ) +} diff --git a/artifacts/glm52-rise-video/src/sheep.tsx b/artifacts/glm52-rise-video/src/sheep.tsx new file mode 100644 index 000000000000..53abb39ee567 --- /dev/null +++ b/artifacts/glm52-rise-video/src/sheep.tsx @@ -0,0 +1,139 @@ +import React from "react" +import { AbsoluteFill, Easing, Img, interpolate, staticFile, useCurrentFrame } from "remotion" + +const c = { + white: "#ffffff", + dim: "rgba(255,255,255,0.72)", + faint: "rgba(255,255,255,0.55)", +} +const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' + +// verified: NZ OpenCode Go, week of Jun 22-28, 2026 (2026-W26) +const TOKENS = 40_915_594_381 // 40.9B +const SHEEP = 23_600_000 // 23.6M +const PER_SHEEP = Math.round(TOKENS / SHEEP) // 1,734 +const nf = new Intl.NumberFormat("en-US") + +// the correct opencode "DATA" wordmark (white, over photo) +function DataWordmark({ height = 30 }: { height?: number }) { + return ( + + + + + + + + + ) +} + +export function NZSheep() { + const frame = useCurrentFrame() + + const count = Math.round( + PER_SHEEP * + interpolate(frame, [18, 90], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: Easing.out(Easing.cubic), + }), + ) + + // slow Ken Burns push-in (scale up only — never reveals an edge) + const zoom = interpolate(frame, [0, 150], [1.06, 1.12], { + extrapolateRight: "clamp", + easing: Easing.inOut(Easing.quad), + }) + + return ( + + {/* the sheep, staring */} + + + {/* legibility scrims */} +
+ + {/* content */} +
+ {/* header */} +
+ +
JUN 22–28, 2026
+
+ + {/* bottom block */} +
+
+ OPENCODE GO · NEW ZEALAND +
+
+ {nf.format(count)} +
+
tokens per sheep
+ +
+
40.9B tokens ÷ 23.6M sheep · last week
+
opencode.ai/data
+
+
+
+ + ) +} diff --git a/artifacts/glm52-rise-video/src/video.tsx b/artifacts/glm52-rise-video/src/video.tsx new file mode 100644 index 000000000000..41466a7e6c39 --- /dev/null +++ b/artifacts/glm52-rise-video/src/video.tsx @@ -0,0 +1,254 @@ +import React from "react" +import { AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig } from "remotion" +import { days, launchIndex, glmWeekTokensT, segments } from "./data" + +// stats.opencode.ai design tokens (light theme) +const c = { + bg: "#ffffff", + ink: "#161616", + muted: "#5c5c5c", + faint: "#808080", + line: "#e6e6e6", + dot: "#ededed", + accent: "#3b5cf6", + accentHi: "#5b78ff", +} + +const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' +const W = 1080 + +const DOT_MASK = + "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E\")" + +const field = segments.filter((s) => !s.hero) +const glmColor = segments.find((s) => s.hero)!.color + +const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)) + +// the correct opencode "DATA" wordmark (from stats.opencode.ai header) +function DataWordmark({ height = 30, color = c.ink }: { height?: number; color?: string }) { + return ( + + + + + + + + + ) +} + +export function GLM52Rise() { + const frame = useCurrentFrame() + const { fps } = useVideoConfig() + + // ---------- virtual camera ---------- + // open zoomed-in on the field, pan right while the blue fills, then pull back to reveal. + const K = [0, 32, 150, 206, 240] + const ease = Easing.inOut(Easing.cubic) + const opt = { extrapolateLeft: "clamp" as const, extrapolateRight: "clamp" as const, easing: ease } + const s = interpolate(frame, K, [1.82, 1.72, 1.72, 1.0, 1.0], opt) + let fx = interpolate(frame, K, [420, 438, 760, 540, 540], opt) + let fy = interpolate(frame, K, [664, 664, 664, 540, 540], opt) + // keep the framing inside the 1080 canvas so edges never reveal black + fx = clamp(fx, 540 / s, W - 540 / s) + fy = clamp(fy, 540 / s, W - 540 / s) + const camera = `translate(${540 - fx * s}px, ${540 - fy * s}px) scale(${s})` + + // ---------- blue sweep (synced to the pan) ---------- + const p = interpolate(frame, [32, 150], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: ease }) + const revealAmount = p * (days.length - launchIndex) + 0.35 + const fillOf = (i: number) => clamp(revealAmount - (i - launchIndex), 0, 1) + + // token number climbs as the camera pulls back and the headline re-enters frame + const tokensT = + glmWeekTokensT * + interpolate(frame, [150, 202], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: Easing.out(Easing.cubic), + }) + + // chart geometry + const chartH = 440 + const chartW = 936 + const gap = 14 + const EXAGGERATE = 2.876 // broken y-axis: GLM-5.2 magnified ~2.9x for emphasis + + return ( + +
+
+ {/* header */} +
+ +
JUN 12–25, 2026
+
+ + {/* headline (static) */} +
+
+ GLM-5.2 broke out +
+
+ From 0 to {tokensT.toFixed(2)}T tokens in a week. +
+
+ + {/* stacked chart */} +
+
+ {/* faint dotted backdrop */} +
+ + {/* columns */} +
+ {days.map((d, i) => { + const glmShare = d.glm / d.total + const blueH = Math.round(glmShare * EXAGGERATE * chartH) + const filled = Math.round(blueH * fillOf(i)) + const slotGap = filled > 2 ? 5 : 0 + const fieldH = chartH - filled - slotGap + const fieldTotal = d.dsf + d.dsp + d.mm + d.others + return ( +
+ {/* gray field of other models (already in place) */} +
+ {field.map((seg) => ( +
+ ))} +
+ {/* GLM-5.2, animates in */} + {filled > 2 && ( +
+ )} +
+ ) + })} +
+
+
+ + {/* day axis */} +
+ {days.map((d, i) => ( +
+ {i === 0 || i === launchIndex || i === days.length - 1 ? d.date : ""} +
+ ))} +
+ + {/* footer */} +
+
+ + GLM-5.2 +
+
opencode.ai/data
+
+
+
+ + ) +} diff --git a/artifacts/glm52-rise-video/sst-env.d.ts b/artifacts/glm52-rise-video/sst-env.d.ts new file mode 100644 index 000000000000..64441936d7a0 --- /dev/null +++ b/artifacts/glm52-rise-video/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/bun.lock b/bun.lock index 7c8bfdba9149..d2a4a7745d70 100644 --- a/bun.lock +++ b/bun.lock @@ -24,17 +24,26 @@ "prettier": "3.6.2", "semver": "^7.6.0", "sst": "catalog:", - "turbo": "2.8.13", + "turbo": "2.10.2", }, }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.16.2", + "version": "1.18.18", "dependencies": { + "@corvu/drawer": "catalog:", + "@dnd-kit/abstract": "0.5.0", + "@dnd-kit/dom": "0.5.0", + "@dnd-kit/helpers": "0.5.0", + "@dnd-kit/solid": "0.5.0", "@kobalte/core": "catalog:", + "@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13-v2.tgz", "@opencode-ai/core": "workspace:*", + "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/session-ui": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@pierre/trees": "1.0.0-beta.4", "@sentry/solid": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/active-element": "2.1.3", @@ -52,11 +61,12 @@ "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "@tanstack/solid-query": "5.91.4", + "@tanstack/solid-virtual": "catalog:", "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", "effect": "catalog:", "fuzzysort": "catalog:", - "ghostty-web": "github:anomalyco/ghostty-web#main", + "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", "luxon": "catalog:", "marked": "catalog:", "marked-shiki": "catalog:", @@ -64,8 +74,8 @@ "shiki": "catalog:", "solid-js": "catalog:", "solid-list": "catalog:", + "solid-presence": "0.2.0", "tailwindcss": "catalog:", - "virtua": "catalog:", }, "devDependencies": { "@happy-dom/global-registrator": "20.0.11", @@ -77,6 +87,7 @@ "@types/luxon": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", + "tw-animate-css": "1.4.0", "typescript": "catalog:", "vite": "catalog:", "vite-plugin-icons-spritesheet": "3.0.1", @@ -85,7 +96,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.16.2", + "version": "1.18.18", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -108,9 +119,46 @@ "@typescript/native-preview": "catalog:", }, }, + "packages/client": { + "name": "@opencode-ai/client", + "dependencies": { + "@opencode-ai/protocol": "workspace:*", + "@opencode-ai/schema": "workspace:*", + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@opencode-ai/core": "workspace:*", + "@opencode-ai/httpapi-codegen": "workspace:*", + "@opencode-ai/server": "workspace:*", + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "effect": "catalog:", + }, + "peerDependencies": { + "effect": "4.0.0-beta.83", + }, + "optionalPeers": [ + "effect", + ], + }, + "packages/codemode": { + "name": "@opencode-ai/codemode", + "version": "1.18.18", + "dependencies": { + "acorn": "8.15.0", + "effect": "catalog:", + "typescript": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.16.2", + "version": "1.18.18", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -146,7 +194,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.16.2", + "version": "1.18.18", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -173,9 +221,9 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.16.2", + "version": "1.18.18", "dependencies": { - "@ai-sdk/anthropic": "3.0.64", + "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", "@ai-sdk/openai-compatible": "2.0.37", "@openauthjs/openauth": "0.0.0-20250322224806", @@ -195,7 +243,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.16.2", + "version": "1.18.18", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -219,7 +267,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.16.2", + "version": "1.18.18", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -239,15 +287,15 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.16.2", + "version": "1.18.18", "bin": { "opencode": "./bin/opencode", }, "dependencies": { "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", - "@ai-sdk/anthropic": "3.0.71", - "@ai-sdk/azure": "3.0.49", + "@ai-sdk/anthropic": "3.0.82", + "@ai-sdk/azure": "3.0.88", "@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", @@ -255,26 +303,28 @@ "@ai-sdk/google": "3.0.73", "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", - "@ai-sdk/mistral": "3.0.27", - "@ai-sdk/openai": "3.0.53", + "@ai-sdk/mistral": "3.0.51", + "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@ai-sdk/togetherai": "2.0.41", "@ai-sdk/vercel": "2.0.39", - "@ai-sdk/xai": "3.0.82", + "@ai-sdk/xai": "3.0.102", "@aws-sdk/credential-providers": "3.1057.0", "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", - "@ff-labs/fff-bun": "0.9.3", + "@ff-labs/fff-bun": "0.9.4", "@lydell/node-pty": "catalog:", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", "@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-sqlite-node": "workspace:*", "@opencode-ai/llm": "workspace:*", + "@opencode-ai/plugin": "workspace:*", + "@opencode-ai/schema": "workspace:*", "@openrouter/ai-sdk-provider": "2.9.0", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", @@ -282,13 +332,14 @@ "@opentelemetry/sdk-trace-base": "2.6.1", "@parcel/watcher": "2.5.1", "@silvia-odwyer/photon-node": "0.3.4", - "ai-gateway-provider": "3.1.2", + "ai-gateway-provider": "3.2.0", "bun-pty": "0.4.8", "cross-spawn": "catalog:", + "diff": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.8.0", + "gitlab-ai-provider": "6.12.1", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -301,7 +352,7 @@ "npm-package-arg": "13.0.2", "semver": "^7.6.3", "turndown": "7.2.0", - "venice-ai-sdk-provider": "2.0.2", + "venice-ai-sdk-provider": "2.1.1", "which": "6.0.1", "xdg-basedir": "5.1.0", "zod": "catalog:", @@ -330,16 +381,16 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.16.2", + "version": "1.18.18", "dependencies": { "@zip.js/zip.js": "2.7.62", + "drizzle-orm": "catalog:", "effect": "catalog:", "electron-context-menu": "4.1.2", "electron-log": "^5", - "electron-store": "^10", - "electron-updater": "^6", + "electron-store": "11.0.2", + "electron-updater": "6.8.9", "electron-window-state": "^5.0.3", - "marked": "^15", }, "devDependencies": { "@actions/artifact": "4.0.0", @@ -356,8 +407,8 @@ "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "@valibot/to-json-schema": "1.6.0", - "electron": "41.2.1", - "electron-builder": "^26", + "electron": "42.3.3", + "electron-builder": "26.15.2", "electron-vite": "^5", "solid-js": "catalog:", "sury": "11.0.0-alpha.4", @@ -384,7 +435,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.16.2", + "version": "1.18.18", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -398,7 +449,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.16.2", + "version": "1.18.18", "dependencies": { "effect": "catalog:", }, @@ -410,10 +461,11 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.16.2", + "version": "1.18.18", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", + "@opencode-ai/session-ui": "workspace:*", "@opencode-ai/ui": "workspace:*", "@pierre/diffs": "catalog:", "@solidjs/meta": "catalog:", @@ -441,7 +493,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.16.2", + "version": "1.18.18", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -457,10 +509,10 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "0.0.0", + "version": "1.18.18", "dependencies": { - "@effect/platform-node": "4.0.0-beta.74", - "@effect/platform-node-shared": "4.0.0-beta.74", + "@effect/platform-node": "4.0.0-beta.83", + "@effect/platform-node-shared": "4.0.0-beta.83", }, "devDependencies": { "@tsconfig/node22": "catalog:", @@ -471,13 +523,26 @@ "typescript": "catalog:", }, "peerDependencies": { - "effect": "4.0.0-beta.74", + "effect": "4.0.0-beta.83", + }, + }, + "packages/httpapi-codegen": { + "name": "@opencode-ai/httpapi-codegen", + "dependencies": { + "effect": "catalog:", + "prettier": "3.6.2", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", }, }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.16.2", + "version": "1.18.18", "dependencies": { + "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", @@ -494,7 +559,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.16.2", + "version": "1.18.18", "bin": { "opencode": "./bin/opencode", }, @@ -504,34 +569,38 @@ "@agentclientprotocol/sdk": "0.21.0", "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", - "@ai-sdk/anthropic": "3.0.71", - "@ai-sdk/azure": "3.0.49", - "@ai-sdk/cerebras": "2.0.41", + "@ai-sdk/anthropic": "3.0.82", + "@ai-sdk/azure": "3.0.88", + "@ai-sdk/cerebras": "2.0.60", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/gateway": "3.0.104", "@ai-sdk/google": "3.0.73", "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", - "@ai-sdk/mistral": "3.0.27", - "@ai-sdk/openai": "3.0.53", + "@ai-sdk/mistral": "3.0.51", + "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", "@ai-sdk/togetherai": "2.0.41", "@ai-sdk/vercel": "2.0.39", - "@ai-sdk/xai": "3.0.82", + "@ai-sdk/xai": "3.0.102", "@aws-sdk/credential-providers": "3.1057.0", "@clack/prompts": "1.0.0-alpha.1", "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", + "@ff-labs/fff-bun": "0.9.4", "@gitlab/opencode-gitlab-auth": "1.3.3", "@modelcontextprotocol/sdk": "1.29.0", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", + "@opencode-ai/codemode": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opencode-ai/plugin": "workspace:*", + "@opencode-ai/protocol": "workspace:*", + "@opencode-ai/schema": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/server": "workspace:*", @@ -554,7 +623,7 @@ "@types/ws": "8.18.1", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", - "ai-gateway-provider": "3.1.2", + "ai-gateway-provider": "3.2.0", "bonjour-service": "1.3.0", "chokidar": "4.0.3", "cross-spawn": "catalog:", @@ -563,18 +632,19 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.8.0", + "gitlab-ai-provider": "6.12.1", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", "htmlparser2": "8.0.2", + "ignore": "7.0.5", "immer": "11.1.4", "jsonc-parser": "3.3.1", "mime-types": "3.0.2", "minimatch": "10.0.3", "npm-package-arg": "13.0.2", "open": "10.1.2", - "opencode-gitlab-auth": "2.0.1", + "opencode-gitlab-auth": "2.1.0", "opencode-poe-auth": "0.0.1", "opentui-spinner": "catalog:", "partial-json": "0.1.7", @@ -586,7 +656,7 @@ "tree-sitter-powershell": "0.25.10", "turndown": "7.2.0", "ulid": "catalog:", - "venice-ai-sdk-provider": "2.0.2", + "venice-ai-sdk-provider": "2.1.1", "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", "ws": "8.21.0", @@ -620,8 +690,9 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.16.2", + "version": "1.18.18", "dependencies": { + "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", "zod": "catalog:", @@ -636,9 +707,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.3.4", - "@opentui/keymap": ">=0.3.4", - "@opentui/solid": ">=0.3.4", + "@opentui/core": ">=0.4.5", + "@opentui/keymap": ">=0.4.5", + "@opentui/solid": ">=0.4.5", }, "optionalPeers": [ "@opentui/core", @@ -646,6 +717,29 @@ "@opentui/solid", ], }, + "packages/protocol": { + "name": "@opencode-ai/protocol", + "dependencies": { + "@opencode-ai/schema": "workspace:*", + "effect": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, + "packages/schema": { + "name": "@opencode-ai/schema", + "dependencies": { + "effect": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/script": { "name": "@opencode-ai/script", "dependencies": { @@ -656,9 +750,23 @@ "@types/semver": "^7.5.8", }, }, + "packages/sdk-next": { + "name": "@opencode-ai/sdk-next", + "dependencies": { + "@opencode-ai/client": "workspace:*", + "@opencode-ai/core": "workspace:*", + "@opencode-ai/server": "workspace:*", + "effect": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.16.2", + "version": "1.18.18", "dependencies": { "cross-spawn": "catalog:", }, @@ -673,9 +781,10 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.16.2", + "version": "1.18.18", "dependencies": { "@opencode-ai/core": "workspace:*", + "@opencode-ai/protocol": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:", }, @@ -685,9 +794,49 @@ "@typescript/native-preview": "catalog:", }, }, + "packages/session-ui": { + "name": "@opencode-ai/session-ui", + "version": "1.18.18", + "dependencies": { + "@kobalte/core": "catalog:", + "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz", + "@opencode-ai/core": "workspace:*", + "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/ui": "workspace:*", + "@pierre/diffs": "catalog:", + "@shikijs/stream": "catalog:", + "@shikijs/transformers": "3.9.2", + "@solid-primitives/bounds": "0.1.3", + "@solid-primitives/event-listener": "2.4.5", + "@solid-primitives/media": "2.3.3", + "@solid-primitives/resize-observer": "2.1.3", + "@solidjs/meta": "catalog:", + "diff": "catalog:", + "dompurify": "3.3.1", + "fuzzysort": "catalog:", + "luxon": "catalog:", + "marked": "catalog:", + "morphdom": "2.7.8", + "motion": "12.34.5", + "remeda": "catalog:", + "remend": "catalog:", + "shiki": "catalog:", + "solid-js": "catalog:", + "solid-list": "catalog:", + "strip-ansi": "7.1.2", + }, + "devDependencies": { + "@tsconfig/node22": "catalog:", + "@types/bun": "catalog:", + "@types/luxon": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + }, + }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.16.2", + "version": "1.18.18", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -700,9 +849,10 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.16.2", + "version": "1.18.18", "dependencies": { "@ibm/plex": "6.4.1", + "@kobalte/core": "catalog:", "@opencode-ai/stats-core": "workspace:*", "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -733,7 +883,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.16.2", + "version": "1.18.18", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -752,7 +902,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.16.2", + "version": "1.18.18", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -771,6 +921,7 @@ "packages/storybook": { "name": "@opencode-ai/storybook", "devDependencies": { + "@opencode-ai/session-ui": "workspace:*", "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", "@storybook/addon-a11y": "^10.2.13", @@ -783,6 +934,7 @@ "@types/node": "catalog:", "@types/react": "18.0.25", "react": "18.2.0", + "react-dom": "18.2.0", "solid-js": "catalog:", "storybook": "^10.2.13", "storybook-solidjs-vite": "^10.0.9", @@ -792,7 +944,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "0.0.0", + "version": "1.18.18", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -801,7 +953,6 @@ "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", - "@solid-primitives/scheduled": "1.5.2", "clipboardy": "4.0.0", "diff": "catalog:", "effect": "catalog:", @@ -820,26 +971,22 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.16.2", + "version": "1.18.18", "dependencies": { "@kobalte/core": "catalog:", - "@opencode-ai/core": "workspace:*", - "@opencode-ai/sdk": "workspace:*", "@pierre/diffs": "catalog:", + "@shikijs/stream": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/bounds": "0.1.3", "@solid-primitives/event-listener": "2.4.5", "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.3", - "@solidjs/meta": "catalog:", - "@solidjs/router": "catalog:", "diff": "catalog:", "dompurify": "3.3.1", "fuzzysort": "catalog:", "katex": "0.16.27", "luxon": "catalog:", "marked": "catalog:", - "marked-katex-extension": "5.1.6", "marked-shiki": "catalog:", "morphdom": "2.7.8", "motion": "12.34.5", @@ -848,28 +995,34 @@ "remeda": "catalog:", "remend": "catalog:", "shiki": "catalog:", - "solid-js": "catalog:", "solid-list": "catalog:", + "solid-sonner": "catalog:", "strip-ansi": "7.1.2", - "virtua": "catalog:", }, "devDependencies": { + "@solidjs/meta": "catalog:", "@tailwindcss/vite": "catalog:", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", "@types/katex": "0.16.7", "@types/luxon": "catalog:", "@typescript/native-preview": "catalog:", + "solid-js": "catalog:", "tailwindcss": "catalog:", + "tw-animate-css": "1.4.0", "typescript": "catalog:", "vite": "catalog:", "vite-plugin-icons-spritesheet": "3.0.1", "vite-plugin-solid": "catalog:", }, + "peerDependencies": { + "@solidjs/meta": "^0.29.0", + "solid-js": "^1.9.0", + }, }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.16.2", + "version": "1.18.18", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -911,15 +1064,22 @@ "tree-sitter-bash", ], "patchedDependencies": { - "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", - "virtua@0.49.1": "patches/virtua@0.49.1.patch", + "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", + "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", + "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", - "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", - "pacote@21.5.0": "patches/pacote@21.5.0.patch", - "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", + "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", + "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", + "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", + "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", + "@ai-sdk/groq@3.0.31": "patches/@ai-sdk%2Fgroq@3.0.31.patch", + "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "pacote@21.5.0": "patches/pacote@21.5.0.patch", + "@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch", + "@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch", }, "overrides": { "@opentui/core": "catalog:", @@ -930,9 +1090,10 @@ }, "catalog": { "@cloudflare/workers-types": "4.20251008.0", - "@effect/opentelemetry": "4.0.0-beta.74", - "@effect/platform-node": "4.0.0-beta.74", - "@effect/sql-sqlite-bun": "4.0.0-beta.74", + "@corvu/drawer": "0.2.4", + "@effect/opentelemetry": "4.0.0-beta.83", + "@effect/platform-node": "4.0.0-beta.83", + "@effect/sql-sqlite-bun": "4.0.0-beta.83", "@hono/standard-validator": "0.2.0", "@hono/zod-validator": "0.4.2", "@kobalte/core": "0.13.11", @@ -940,18 +1101,20 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.3.4", - "@opentui/keymap": "0.3.4", - "@opentui/solid": "0.3.4", - "@pierre/diffs": "1.1.0-beta.18", + "@opentui/core": "0.4.5", + "@opentui/keymap": "0.4.5", + "@opentui/solid": "0.4.5", + "@pierre/diffs": "1.2.10", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", "@sentry/vite-plugin": "4.6.0", + "@shikijs/stream": "4.2.0", "@solid-primitives/storage": "4.3.3", "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", "@tailwindcss/vite": "4.1.11", + "@tanstack/solid-virtual": "3.13.32", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", "@types/bun": "1.3.13", @@ -966,25 +1129,25 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-rc.2", "drizzle-orm": "1.0.0-rc.2", - "effect": "4.0.0-beta.74", + "effect": "4.0.0-beta.83", "fuzzysort": "3.1.0", "hono": "4.10.7", "hono-openapi": "1.1.2", "luxon": "3.6.1", - "marked": "17.0.1", + "marked": "18.0.7", "marked-shiki": "1.2.1", - "opentui-spinner": "0.0.6", + "opentui-spinner": "0.0.7", "remeda": "2.26.0", "remend": "1.3.0", "semver": "7.7.4", - "shiki": "3.20.0", + "shiki": "4.2.0", "solid-js": "1.9.10", "solid-list": "0.3.0", + "solid-sonner": "0.3.1", "sst": "4.13.1", "tailwindcss": "4.1.11", "typescript": "5.8.2", "ulid": "3.0.1", - "virtua": "0.49.1", "vite": "7.1.4", "vite-plugin-solid": "2.11.10", "zod": "4.1.8", @@ -1012,23 +1175,23 @@ "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.81", "@ai-sdk/openai": "3.0.67", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-PsSh7a6qW+3kQXPs1kD4wDwuZby0t1PIaB6j/1aMKmPFJ5LxcIcULLMF/bjITLt5o/8lc0t6TXIwG0zlhH7uZw=="], - "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="], + "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="], - "@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="], + "@ai-sdk/azure": ["@ai-sdk/azure@3.0.88", "", { "dependencies": { "@ai-sdk/deepseek": "2.0.47", "@ai-sdk/openai": "3.0.84", "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RRjZkB1lYplh8dpBarnvkl1j7sYLHsyXua7erL3oNcMK7fHcl4bPO5C7iQhD1O/DqD/zCceDifnege1s+8yEvw=="], "@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="], "@ai-sdk/cohere": ["@ai-sdk/cohere@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OqcCq2PiFY1dbK/0Ck45KuvE8jfdxRuuAE9Y5w46dAk6U+9vPOeg1CDcmR+ncqmrYrhRl3nmyDttyDahyjCzAw=="], - "@ai-sdk/deepgram": ["@ai-sdk/deepgram@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-VscTV68g6sXRY4O1yl72/O8y6+tBDvSQax6bqX06hRKWBGxsJ8Jr3LZsNmZnK9Od5Icx565ijK0QgrlNaN4TdQ=="], + "@ai-sdk/deepgram": ["@ai-sdk/deepgram@2.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DPoKiCXDwzopJI/pHcZnXaycZ5qqCL4xCWcfsb1s8M8OUPchK2GUHcCXt6v056fAqtKWLF/hrW+RcHFFY0qyHQ=="], "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="], - "@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="], + "@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MzcQ321JO8OY+TVLFI81A7cIIuoeLLxrLCDD+8C1E3Ro6UFyfMtRXo9bw9OhTMRSDMo6hgSDOo4Fekz8aJtQYQ=="], - "@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EtvsWfGrqx3OhzJdoi82qH+4yzEPPKZr2utyQ+w8cHKoFeg0+8Lou9Z3uixy73WEwz8Z1+AR8QT9fZ64AWGYPA=="], + "@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XFONX6rAsu6d13cJVUfZfkq4a+qdThlxvoEfzYlSRa1AvALlzwX7Y6bunXxevuifT3n882+nDdWrdiYvFP0+Fw=="], - "@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.53", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.48", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HjeiGsdxSzrCkOf2l2V+K+opzlqxBtduBq6BCiohAdgQk2KdZmI/67SMkBM6Kdze/BjUXiZlv0d7zNICPhxVDA=="], + "@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.76", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.67", "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yg1ulgemh6BLMrs2vBNbtjV70NhenFI3Z7psB7s94FOEcGevvyV1qdFoqsgBZ7QyuUGwnC645c+eBFFtPAr5SQ=="], "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="], @@ -1038,7 +1201,7 @@ "@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="], - "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="], + "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="], "@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="], @@ -1054,7 +1217,7 @@ "@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="], - "@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="], + "@ai-sdk/xai": ["@ai-sdk/xai@3.0.102", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.56", "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NeQyOR7OCqDMgaLS4uNX/ep/HrwUzzFYLzXQSRoqLy2jsnqxAJhsgltRwAwf+ADjyPBIAKEOestWnIQA+LrLrQ=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -1280,6 +1443,8 @@ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + "@bufbuild/protobuf": ["@bufbuild/protobuf@2.12.0", "", {}, "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA=="], "@bufbuild/protoplugin": ["@bufbuild/protoplugin@2.12.0", "", { "dependencies": { "@bufbuild/protobuf": "2.12.0", "@typescript/vfs": "^1.6.2", "typescript": "5.4.5" } }, "sha512-ORlDITp8AFUXzIhLRoMCG+ud+D3MPKWb5HQXBoskMMnjeyEjE1H1qLonVNPyOr8lkx3xSfYUo8a0dvOZJVAzow=="], @@ -1308,6 +1473,10 @@ "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251008.0", "", {}, "sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA=="], + "@corvu/dialog": ["@corvu/dialog@0.2.4", "", { "dependencies": { "@corvu/utils": "~0.4.2", "solid-dismissible": "~0.1.1", "solid-focus-trap": "~0.1.8", "solid-presence": "~0.2.0", "solid-prevent-scroll": "~0.1.10" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-n54vJq+fOy8GVrnYBdJpD6JXNuyx7LOeMrRxwzAvZnYGpW8+AA12tnb/P/2emJj/HjOO5otheGKb0breshdFlA=="], + + "@corvu/drawer": ["@corvu/drawer@0.2.4", "", { "dependencies": { "@corvu/dialog": "~0.2.4", "@corvu/utils": "~0.4.2", "@solid-primitives/memo": "^1.4.1", "solid-transition-size": "~0.1.4" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-7jQoGZ8ROB9CmXam2nMY2wEskU3IoFwZQywkF/7vrBc/edGsPv7mOVQ1GN6G+4nd7nrMZ3UtqHAhmRh9V0azlw=="], + "@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -1316,23 +1485,37 @@ "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], + "@dnd-kit/abstract": ["@dnd-kit/abstract@0.5.0", "", { "dependencies": { "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA=="], + + "@dnd-kit/collision": ["@dnd-kit/collision@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ=="], + + "@dnd-kit/dom": ["@dnd-kit/dom@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/collision": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw=="], + + "@dnd-kit/geometry": ["@dnd-kit/geometry@0.5.0", "", { "dependencies": { "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A=="], + + "@dnd-kit/helpers": ["@dnd-kit/helpers@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-i4y+51/icSw+OHMr/su19qhnmNhAzh8PnBwXvapFYTd+64oodIyJRiRkB+hhfxAfnur7RYSW8qacDTrXjg2XOg=="], + + "@dnd-kit/solid": ["@dnd-kit/solid@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/dom": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" }, "peerDependencies": { "solid-js": "^1.8.0" } }, "sha512-IKDqVZICS0jEeUzpJMIIF61w0WA4zisyx9U7K7Skbmkb/kQSDa3lB0cOc0947RwSO+ALoxytRNOuoNfyOIm3lQ=="], + + "@dnd-kit/state": ["@dnd-kit/state@0.5.0", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w=="], + "@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], - "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.74", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.74" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-flpyqLPyr+THSe6ZCGRZl6hi+FqxbIXNSkslKGiRJAjbPabam9mSp7R3aC8biIMt6xE4Fd0LNfo4p2GplUkm2Q=="], + "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.83", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.83" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-cPfCfp/ghu0itbX6Dqjdr4N0rbjng5ON4sUpnLHV5JJySG8zZpWmuOZLWIrfrNKT2ctYR1BYmp1aYCgkItaJLw=="], - "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.74", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.74", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74", "ioredis": "^5.7.0" } }, "sha512-/W16mKqxvhWINLjufzc0log1sl57exXQfwd+em398/zKCbmU3S7snXTDMN6w0ju2TtgK35qrsoGBXEochij6Sg=="], + "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.83", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.83", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83", "ioredis": "^5.7.0" } }, "sha512-RmpVGu/+X/Bif3/g1Rzj8oFzTOknoVB3yHCa0b179vytPpKe+Kj9ZwKNcAnKWqHUDkbSPBq1Ca60mvOHr2/+LQ=="], - "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.74", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-C6C2hXixNcZXLaFF2u7B/FtOsqpdY7luaPuiGFBJza0P7EnYDkwaT3kB6lv7l/qctmkADc24qOsSCWIKRbC4jg=="], + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.83", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-+yr/+PJmKTgmJq1QOINSBPgLu7Cjc4CZcotBXnGjyDEizOmimFgTkN2B8PBJAKIKUWYWfobjXqC+58/VhhPKAw=="], - "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.74", "", { "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-RVMRVY7NhSoAp9cAAyy4TT6dt6NNZjOpWeqticoho9HNBukxQSUcu/kjcz4Iq9eoQfXadmepu8kZqtdZULM/fg=="], + "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.83", "", { "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-6OaxLsWffxkh9pXYUSyj/AxjVb9URY2rG9U6atjxClWy30Jx77R9Pm3Rrc7cQ63kQurePavEw1bQbzQ/SILiQQ=="], "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], "@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="], - "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], + "@electron/get": ["@electron/get@5.0.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^3.0.0", "graceful-fs": "^4.2.11", "progress": "^2.0.3", "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "optionalDependencies": { "undici": "^7.24.4" } }, "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA=="], "@electron/notarize": ["@electron/notarize@2.5.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="], @@ -1444,23 +1627,23 @@ "@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="], - "@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.9.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-isGuuEbAo7D6psAllm4+TRONxmDfhlmm548IjsG5hEH4I/pwTTTtrRg4lpMDwQ/cD5I3kEL2KVEYdlwuyFod8w=="], + "@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xyivu2xB++O5xXDx5Qm50JsU2aXt8YgXlGVhH/HE7UMYDrE6L6f1RYdYs8Y0bn0D3D0+bFBrN5ELPszK9E4Wbw=="], - "@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.9.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vJMyCHtE5/CqCmvH7kEDSkUK9/YImoGZuIrRd6yLBjpSTtwyr0QIYjXDsFSj8a4eyxP3ieZWBw9z+uekPZ4YHw=="], + "@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xLooAhCnTDCipPSMMZz7kGF3lhRHx6aP5fb6DJ0Ipyw/w/UWJb+xITJFszUl/QnIBoJ/qjDc93/FZMo1dk6gVA=="], - "@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.9.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-bapVTzIJZ40WmGYpAN+X3hIOqeynNTH1WPTp6S2pDMj6WQIG0lO4zWboNRAhVxIdsBq7vJwiBm4BKN+8Wp4wzg=="], + "@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-m5+8vA+1veaUUWonwva1WsU6m1HRm8CpYUzr06KDB65mewlmPbqz7+Fh7hjEfiD8C4mHVHe6RysULvAH1yhsdw=="], - "@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.9.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-opdtJbCmDB/SjHx+IaM6DF6UpYUZ8saXbwiAHamqg8ywhBWQoGzTo66BBwbaf6kd7sv7hJsYrUVBqLJhZGfL4A=="], + "@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-EMeWm7CSTVkizy4ZEzUkLDP024tVcbCUthduuIhekFQRDsiaAze0YboIylWb9HBHJCZlCCoZrWAl4nnJbsX7AA=="], - "@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.9.3", "", { "os": "linux", "cpu": "x64" }, "sha512-74kucsnuCsp0daZQGtg0YYJL8h8ypt/efzSQjEuja2GPLdZrW9zVO1p+EWP9FZIt0bAf1o71W3PWjSEa3dTLUQ=="], + "@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-pglE0uLkhnlE6bStXqfgUjYTSj+2sVwXaPfoA0QksidAsQor6NRt8004mygzC9DPubgHq5B9QezPfEwigKaP9Q=="], - "@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.9.3", "", { "os": "linux", "cpu": "x64" }, "sha512-zgbzi24qWaE1l8bFweApM8Zd1ymxfP5tf9yX9k+PqmOGdGQhGWwbWTxB6UCUu+BiLPd+78Lxzp4oIBoSsZzejA=="], + "@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-VNKxgl8qs3aTfXViX7lqRK1aLu311h8dtBFqG4Scv+9Oi7WprybUp5L7IZ8sxKERaDAaiJMXHodXa1c90QdK8w=="], - "@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.9.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-2ZB3LgEXWY0BJVpN6zr2JeuGYQbOZhNVZYYkKGY9g48L/nUuuB2X1HzQTLQ0zPipmFoPG7dUFlTjl+qmQhJPRw=="], + "@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-uFEt0aNL54vQxq1ivjxRuo+thnhS4wLqa4INl4VXnXJUmwB42XXxD+gsj7vzhBLLx4cFf0aWgy/+TVDR8yjZtQ=="], - "@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.9.3", "", { "os": "win32", "cpu": "x64" }, "sha512-K6PycT3FluRUEtOqsySbq8oHxP8XeyvdWtnxMlnaSSLc5LKlWg3CKvc+kxfq7UkpySA9LlPk+Qp/C1IvJ890QA=="], + "@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Yd2Eyxj+slWv+0QDW9/xBpu9FXq+hwD0rXQD5184/88d+xwWCLKhEP2w8I6OO9XCg+kLT79UJb+k0WwXUtBtMw=="], - "@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.9.3", "", { "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.9.3", "@ff-labs/fff-bin-darwin-x64": "0.9.3", "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.3", "@ff-labs/fff-bin-linux-arm64-musl": "0.9.3", "@ff-labs/fff-bin-linux-x64-gnu": "0.9.3", "@ff-labs/fff-bin-linux-x64-musl": "0.9.3", "@ff-labs/fff-bin-win32-arm64": "0.9.3", "@ff-labs/fff-bin-win32-x64": "0.9.3" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-PPSsmSf1+xD/8eLelBDYFcmlmQUPRCm+GO4K/PgtuLtLu0CWsoxyStykgjw+0GP3bTUVNdHK1FYwESk0hmY6lg=="], + "@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.9.4", "", { "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.9.4", "@ff-labs/fff-bin-darwin-x64": "0.9.4", "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.4", "@ff-labs/fff-bin-linux-arm64-musl": "0.9.4", "@ff-labs/fff-bin-linux-x64-gnu": "0.9.4", "@ff-labs/fff-bin-linux-x64-musl": "0.9.4", "@ff-labs/fff-bin-win32-arm64": "0.9.4", "@ff-labs/fff-bin-win32-x64": "0.9.4" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-7HUraaK/g5dStAnuKAuzsXVOQvqqX0ylo5G+DxYwsCjCDc42bjoEAAHqz/3Sn3raUNw97KMoz87XR9QyrLEfVw=="], "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], @@ -1674,6 +1857,8 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "@nodable/entities": ["@nodable/entities@2.1.1", "", {}, "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -1762,6 +1947,10 @@ "@opencode-ai/cli": ["@opencode-ai/cli@workspace:packages/cli"], + "@opencode-ai/client": ["@opencode-ai/client@workspace:packages/client"], + + "@opencode-ai/codemode": ["@opencode-ai/codemode@workspace:packages/codemode"], + "@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"], "@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"], @@ -1788,16 +1977,26 @@ "@opencode-ai/http-recorder": ["@opencode-ai/http-recorder@workspace:packages/http-recorder"], + "@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"], + "@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"], "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], + "@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"], + + "@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"], + "@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"], "@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"], + "@opencode-ai/sdk-next": ["@opencode-ai/sdk-next@workspace:packages/sdk-next"], + "@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"], + "@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"], + "@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"], "@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"], @@ -1842,27 +2041,27 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@opentui/core": ["@opentui/core@0.3.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="], + "@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="], - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="], + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="], - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="], + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="], - "@opentui/keymap": ["@opentui/keymap@0.3.4", "", { "dependencies": { "@opentui/core": "0.3.4" }, "peerDependencies": { "@opentui/react": "0.3.4", "@opentui/solid": "0.3.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8fo6BZWQgCjANfbKkzPo0ghAzS1E7TlHjDDS+SUhrX01qEUO1clFTRssKluHbXd2UJY1Ehle01TV5bFmY78f8w=="], + "@opentui/keymap": ["@opentui/keymap@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5" }, "peerDependencies": { "@opentui/react": "0.4.5", "@opentui/solid": "0.4.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg=="], - "@opentui/solid": ["@opentui/solid@0.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="], + "@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -2108,9 +2307,21 @@ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="], - "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.18", "", { "dependencies": { "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-7ZF3YD9fxdbYsPnltz5cUqHacN7ztp8RX/fJLxwv8wIEORpP4+7dHz1h/qx3o4EW2xUrIhmbM8ImywLasB787Q=="], + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.7.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg=="], + + "@peculiar/json-schema": ["@peculiar/json-schema@1.1.12", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w=="], + + "@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="], + + "@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="], + + "@pierre/diffs": ["@pierre/diffs@1.2.10", "", { "dependencies": { "@pierre/theme": "1.0.3", "@pierre/theming": "0.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-rPeAmDWarxFVTQpaf4y6wTxjZxU44xKJKoJti2zU21P06DVd9nRHZX+xSIObLB307Qjpaesyb1x/j0z94t7vLw=="], - "@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="], + "@pierre/theme": ["@pierre/theme@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="], + + "@pierre/theming": ["@pierre/theming@0.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-1thlEtJbqdyLzc1ZS2KQa1q7FzDGHT4dTEdKHoyQjOMeWWOmbVG5/ndEfOKfAb5Fzkz8cNJrOjFLiZoDH/A03A=="], + + "@pierre/trees": ["@pierre/trees@1.0.0-beta.4", "", { "dependencies": { "preact": "11.0.0-beta.0", "preact-render-to-string": "6.6.5" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag=="], "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], @@ -2126,6 +2337,8 @@ "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + "@preact/signals-core": ["@preact/signals-core@1.14.3", "", {}, "sha512-m0K3vnbSLC5rHs2ZVfeAMvBtT1zIyq4mxx5OlNncSgMj5Iz6W5Rn3kPrDxAC+iIKmiVe0lSl6U37t5ZkEWoVAw=="], + "@protobuf-ts/plugin": ["@protobuf-ts/plugin@2.11.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.4.0", "@bufbuild/protoplugin": "^2.4.0", "@protobuf-ts/protoc": "^2.11.1", "@protobuf-ts/runtime": "^2.11.1", "@protobuf-ts/runtime-rpc": "^2.11.1", "typescript": "^3.9" }, "bin": { "protoc-gen-ts": "bin/protoc-gen-ts", "protoc-gen-dump": "bin/protoc-gen-dump" } }, "sha512-HyuprDcw0bEEJqkOWe1rnXUP0gwYLij8YhPuZyZk6cJbIgc/Q0IFgoHQxOXNIXAcXM4Sbehh6kjVnCzasElw1A=="], "@protobuf-ts/protoc": ["@protobuf-ts/protoc@2.11.1", "", { "bin": { "protoc": "protoc.js" } }, "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg=="], @@ -2314,13 +2527,17 @@ "@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="], - "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="], + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g=="], + + "@shikijs/langs": ["@shikijs/langs@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ=="], - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="], + "@shikijs/primitive": ["@shikijs/primitive@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA=="], - "@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="], + "@shikijs/stream": ["@shikijs/stream@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0" }, "peerDependencies": { "react": "^19.0.0", "solid-js": "^1.9.0", "vue": "^3.2.0" }, "optionalPeers": ["react", "solid-js", "vue"] }, "sha512-OaMUUStdIZ+l1GJad9uVACR3Xvgwo4y+RmEuDMU62cgFMMg1IBCaIFmvzAR2HiCpGtwoc/qPfpNnP+ivgrPXZg=="], - "@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="], + "@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="], "@shikijs/transformers": ["@shikijs/transformers@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/types": "3.9.2" } }, "sha512-MW5hT4TyUp6bNAgTExRYLk1NNasVQMTCw1kgbxHcEC0O5cbepPWaB+1k+JzW9r3SP2/R8kiens8/3E6hGKfgsA=="], @@ -2342,7 +2559,7 @@ "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], - "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], "@slack/bolt": ["@slack/bolt@3.22.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/oauth": "^2.6.3", "@slack/socket-mode": "^1.3.6", "@slack/types": "^2.13.0", "@slack/web-api": "^6.13.0", "@types/express": "^4.16.1", "@types/promise.allsettled": "^1.0.3", "@types/tsscmp": "^1.0.0", "axios": "^1.7.4", "express": "^4.21.0", "path-to-regexp": "^8.1.0", "promise.allsettled": "^1.0.2", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" } }, "sha512-iKDqGPEJDnrVwxSVlFW6OKTkijd7s4qLBeSufoBsTM0reTyfdp/5izIQVkxNfzjHi3o6qjdYbRXkYad5HBsBog=="], @@ -2460,6 +2677,8 @@ "@solid-primitives/media": ["@solid-primitives/media@2.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="], + "@solid-primitives/memo": ["@solid-primitives/memo@1.5.0", "", { "dependencies": { "@solid-primitives/scheduled": "^1.5.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-nSMHpFdnOMP88t7lqtktUXJlhQdJk0BQs2v3jUqcn+OtFwUm27Oa/coKl1BHDxL/65jR5drjqxxvuhaIpiUi0w=="], + "@solid-primitives/props": ["@solid-primitives/props@3.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-XzG6en9gSFwmvbKcATm2BxL63HegZ+BAG5fmHi8jyBppQHcaths7ffz+6vYvwYy3nlgLa20ufJLj7tst+PcHFA=="], "@solid-primitives/refs": ["@solid-primitives/refs@1.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA=="], @@ -2564,6 +2783,10 @@ "@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="], + "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.32", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-yhX4A4Kgn+wyTg6Mmu8+zwoMTwjz4K1ucvLfRJ8f0rPGDDAIqSaf0v6oU0yT9+SvrjmUaZQ0VX7g4byexbhNng=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], @@ -2580,6 +2803,18 @@ "@tufjs/models": ["@tufjs/models@4.1.0", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.1.1" } }, "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww=="], + "@turbo/darwin-64": ["@turbo/darwin-64@2.10.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-wBM3ObqOWnKUDmg7QfUFDkDHPFUAJmrYlYqmEM8jMPAPA/I6wRJIbWimeQUqhOiQ8xPKhzyWM+xaiUP0wz8FEQ=="], + + "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/Cq0joWnuMjDPfhjbFP4sv+C/7gkQ415zlaO4XUzD5EZxbtrKgXKvuuydMvogG8GeUnN1aDltW71RlmEfpjbyw=="], + + "@turbo/linux-64": ["@turbo/linux-64@2.10.2", "", { "os": "linux", "cpu": "x64" }, "sha512-mMsf5IIhiKuceEXNstd25IbadjBXZ0amxzFOqliEzJX6HyeeHdBQPVSY583PWqYDyqM/FB8d5ZjkthfBSeuH3Q=="], + + "@turbo/linux-arm64": ["@turbo/linux-arm64@2.10.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Wcng1i2kaKmXutmwxT9MUoYZvdaIekXAdlGr4+0TpgbhGLw7nDuEcRBFrxb5BbRoX1d1q8SpdRxLc45TvDZIdQ=="], + + "@turbo/windows-64": ["@turbo/windows-64@2.10.2", "", { "os": "win32", "cpu": "x64" }, "sha512-SsNhM7Ho7EpAdwtrJKBOic9Hso23vu6Dp0gAfLOvUFjPzurr/sGQlXZEvr6z89ne4RDOypTwz5CBDrixpMKtXw=="], + + "@turbo/windows-arm64": ["@turbo/windows-arm64@2.10.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gf+S7ICAdimT/n02bOuVWKvhHnct/HYjZg3oBNIz5hZ9ZyWHbQim9J3P5Qip8WpX0ksxF7eaBVziJCuLnjhqDg=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], @@ -2768,6 +3003,8 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.8", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.8", "vitest": "4.1.8" }, "optionalPeers": ["@vitest/browser"] }, "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw=="], + "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], "@vitest/mocker": ["@vitest/mocker@4.1.7", "", { "dependencies": { "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA=="], @@ -2814,7 +3051,7 @@ "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], - "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -2826,7 +3063,7 @@ "ai": ["ai@6.0.168", "", { "dependencies": { "@ai-sdk/gateway": "3.0.104", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ=="], - "ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="], + "ai-gateway-provider": ["ai-gateway-provider@3.2.0", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.117", "@ai-sdk/anthropic": "^3.0.84", "@ai-sdk/azure": "^3.0.74", "@ai-sdk/cerebras": "^2.0.56", "@ai-sdk/cohere": "^3.0.38", "@ai-sdk/deepgram": "^2.0.35", "@ai-sdk/deepseek": "^2.0.38", "@ai-sdk/elevenlabs": "^2.0.35", "@ai-sdk/fireworks": "^2.0.56", "@ai-sdk/google": "^3.0.82", "@ai-sdk/google-vertex": "^4.0.145", "@ai-sdk/groq": "^3.0.41", "@ai-sdk/mistral": "^3.0.39", "@ai-sdk/openai": "^3.0.71", "@ai-sdk/perplexity": "^3.0.35", "@ai-sdk/xai": "^3.0.95", "@openrouter/ai-sdk-provider": "^2.10.0" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-IGSV96IqAfiZd20CWSMVQk5sVeLcJR2uQcoWLB8GdkxyvQrsU3x4U0o1Ok6bfVJug7SrlX6I8ibz9cHIkUyRtg=="], "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -2852,7 +3089,7 @@ "app-builder-bin": ["app-builder-bin@5.0.0-alpha.12", "", {}, "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w=="], - "app-builder-lib": ["app-builder-lib@26.8.1", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.8.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.8.1", "electron-builder-squirrel-windows": "26.8.1" } }, "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw=="], + "app-builder-lib": ["app-builder-lib@26.15.2", "", { "dependencies": { "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@noble/hashes": "^2.2.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", "builder-util": "26.15.0", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.15.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.2.5", "pkijs": "^3.4.0", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "unzipper": "^0.12.3", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.15.2", "electron-builder-squirrel-windows": "26.15.2" } }, "sha512-3mYfKOjr/ZY7gFESOcq8kylBMgGPpmlQYnpBVit4p6zIg0t/8bkWBILdMMtnjFyN2jllyBf225T8dLlz3D6oBQ=="], "archiver": ["archiver@7.0.1", "", { "dependencies": { "archiver-utils": "^5.0.2", "async": "^3.2.4", "buffer-crc32": "^1.0.0", "readable-stream": "^4.0.0", "readdir-glob": "^1.1.2", "tar-stream": "^3.0.0", "zip-stream": "^6.0.1" } }, "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ=="], @@ -2880,12 +3117,16 @@ "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], + "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg=="], + "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], @@ -2916,6 +3157,8 @@ "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + "aws4": ["aws4@1.13.2", "", {}, "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="], + "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], "axe-core": ["axe-core@4.11.4", "", {}, "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA=="], @@ -2974,6 +3217,8 @@ "blob-to-buffer": ["blob-to-buffer@1.2.9", "", {}, "sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA=="], + "bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="], + "body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="], "bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="], @@ -3006,11 +3251,11 @@ "buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="], - "builder-util": ["builder-util@26.8.1", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw=="], + "builder-util": ["builder-util@26.15.0", "", { "dependencies": { "@types/debug": "^4.1.6", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-dUx+HxVbiNsNQ4mGe1PyoC/tBmsHwBNDLdBuqWCj+rhHFE9lHgrXiGYKAM1uNlznhAaUSyMlms84VeSSr3gOBA=="], - "builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], + "builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="], - "bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="], + "bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="], "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], @@ -3020,6 +3265,8 @@ "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "bytestreamjs": ["bytestreamjs@2.0.1", "", {}, "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ=="], + "c12": ["c12@3.3.3", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^17.2.3", "exsolve": "^1.0.8", "giget": "^2.0.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.0.0", "pkg-types": "^2.3.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q=="], "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], @@ -3132,7 +3379,7 @@ "condense-newlines": ["condense-newlines@0.2.1", "", { "dependencies": { "extend-shallow": "^2.0.1", "is-whitespace": "^0.3.0", "kind-of": "^3.0.2" } }, "sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg=="], - "conf": ["conf@14.0.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^9.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.4.0" } }, "sha512-L6BuueHTRuJHQvQVc6YXYZRtN5vJUtOdCTLn0tRYYV5azfbAFcPghB5zEE40mVrV6w7slMTqUfkDomutIK14fw=="], + "conf": ["conf@15.1.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.5.0" } }, "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og=="], "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], @@ -3152,7 +3399,7 @@ "cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="], - "core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], @@ -3280,7 +3527,7 @@ "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], - "dmg-builder": ["dmg-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg=="], + "dmg-builder": ["dmg-builder@26.15.2", "", { "dependencies": { "app-builder-lib": "26.15.2", "builder-util": "26.15.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-fMkjRqKyPtsz4Kzu/qGP0BGjqzMCIgp+/7kw/u6YH6lvn/8hvL3c0TXhoFayBoYdpPCnEinnCHztd4bW7/jetA=="], "dmg-license": ["dmg-license@1.0.11", "", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="], @@ -3314,6 +3561,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], @@ -3322,13 +3571,13 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], + "effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], - "electron": ["electron@41.2.1", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-teeRThiYGTPKf/2yOW7zZA1bhb91KEQ4yLBPOg7GxpmnkLFLugKgQaAKOrCgdzwsXh/5mFIfmkm+4+wACJKwaA=="], + "electron": ["electron@42.3.3", "", { "dependencies": { "@electron/get": "^5.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-0MwYp9wTb7TrtTalOYqeW+suqd9T/Znstr/nDLKqFGIjHdBZX339guo3mQqTPURRZ/UQmYM4uMpzKpI5wLptfQ=="], - "electron-builder": ["electron-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw=="], + "electron-builder": ["electron-builder@26.15.2", "", { "dependencies": { "app-builder-lib": "26.15.2", "builder-util": "26.15.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.2", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-veKM9+dCljaC5A74Pwc0ZWQ9arOHREXWh9hUIf8NGg49ch7x+IB4QhbMzIrV5ONZIXM2OEkaxW11cAPjPtoi4A=="], "electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "electron-winstaller": "5.4.0" } }, "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA=="], @@ -3340,13 +3589,13 @@ "electron-log": ["electron-log@5.4.4", "", {}, "sha512-istWgaXjBfURBSS8LWVW9C3jsc6+ac+tY1lXrQEOTp0lVj+a4OlO1Tmqb36GgnEUDv92DGC9VI1HNXwJinWpgA=="], - "electron-publish": ["electron-publish@26.8.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w=="], + "electron-publish": ["electron-publish@26.15.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.15.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-BMgMHOyexWn0UnOC+Afffw0DMrr0yfLp4U8YsLXwoJ3Da7LS7WUnz21teYZqO0gaApE1KgsjREWmbPqvF5JcPg=="], - "electron-store": ["electron-store@10.1.0", "", { "dependencies": { "conf": "^14.0.0", "type-fest": "^4.41.0" } }, "sha512-oL8bRy7pVCLpwhmXy05Rh/L6O93+k9t6dqSw0+MckIc3OmCTZm6Mp04Q4f/J0rtu84Ky6ywkR8ivtGOmrq+16w=="], + "electron-store": ["electron-store@11.0.2", "", { "dependencies": { "conf": "^15.0.2", "type-fest": "^5.0.1" } }, "sha512-4VkNRdN+BImL2KcCi41WvAYbh6zLX5AUTi4so68yPqiItjbgTjqpEnGAqasgnG+lB6GuAyUltKwVopp6Uv+gwQ=="], "electron-to-chromium": ["electron-to-chromium@1.5.364", "", {}, "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw=="], - "electron-updater": ["electron-updater@6.8.3", "", { "dependencies": { "builder-util-runtime": "9.5.1", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ=="], + "electron-updater": ["electron-updater@6.8.9", "", { "dependencies": { "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig=="], "electron-vite": ["electron-vite@5.0.0", "", { "dependencies": { "@babel/core": "^7.28.4", "@babel/plugin-transform-arrow-functions": "^7.27.1", "cac": "^6.7.14", "esbuild": "^0.25.11", "magic-string": "^0.30.19", "picocolors": "^1.1.1" }, "peerDependencies": { "@swc/core": "^1.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@swc/core"], "bin": { "electron-vite": "bin/electron-vite.js" } }, "sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ=="], @@ -3362,6 +3611,8 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], "engine.io-client": ["engine.io-client@6.6.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.20.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg=="], @@ -3372,7 +3623,7 @@ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], @@ -3596,13 +3847,13 @@ "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], - "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#20bd361", {}, "anomalyco-ghostty-web-20bd361", "sha512-dW0nwaiBBcun9y5WJSvm3HxDLe5o9V0xLCndQvWonRVubU8CS1PHxZpLffyPt1YujPWC13ez03aWxcuKBPYYGQ=="], + "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#83c0a07", {}, "anomalyco-ghostty-web-83c0a07", "sha512-Lf2v1agHkVUpMpHBWWuCZrhOEmcwwin5/Hboc9rZwQ7/CKkIh5rU1r1CvfLlhkMoFv+ed8z52RZ8hkzGZZj3MQ=="], "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - "gitlab-ai-provider": ["gitlab-ai-provider@6.8.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": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-KwHASXkHtDcgrzTXZVp9Dyx6t8m9nK0R2fCm47MWcxxQ1kOBt3f2LZugtu1kOby8i4Sbd+kvBSYM66PGkDclng=="], + "gitlab-ai-provider": ["gitlab-ai-provider@6.12.1", "", { "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": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Qn5iHqvjG8yktI5MWaUgdRR94l7O4WtYW0CAbhsCh1Tj0Fei/DeprOYPVyf4Nht1Ix6U2PXSYM32QOHI6Z2TDw=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], @@ -3882,6 +4133,12 @@ "isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], + + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], + + "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + "iterate-iterator": ["iterate-iterator@1.0.2", "", {}, "sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw=="], "iterate-value": ["iterate-value@1.0.2", "", { "dependencies": { "es-get-iterator": "^1.0.2", "iterate-iterator": "^1.0.1" } }, "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ=="], @@ -4050,15 +4307,15 @@ "magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="], + "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + "make-fetch-happen": ["make-fetch-happen@15.0.6", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", "@npmcli/redact": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", "ssri": "^13.0.0" } }, "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw=="], "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], - - "marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="], + "marked": ["marked@18.0.7", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA=="], "marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="], @@ -4294,6 +4551,8 @@ "node-html-parser": ["node-html-parser@7.1.0", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ=="], + "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], + "node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="], "node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="], @@ -4362,13 +4621,13 @@ "opencode": ["opencode@workspace:packages/opencode"], - "opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.1", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-1EMZHdbADLMVaTVLQ6C/V8uVMDr6MP++osj2lmOecowtn46AafP/w6ADkV4AN/ddjA1rob5cWpMuf/iME6DI6A=="], + "opencode-gitlab-auth": ["opencode-gitlab-auth@2.1.0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-ZCDYaY0V8Se6hOH2tqZqqcskrd0xLTgfiGhU0J1igkUP52oFtN9eSwxOPLT0ctvNXUq8b+zOmJ4sskAQoC/IUA=="], "opencode-poe-auth": ["opencode-poe-auth@0.0.1", "", { "dependencies": { "open": "^10.0.0", "poe-oauth": "*" }, "peerDependencies": { "@opencode-ai/plugin": "*" } }, "sha512-cXqTlS6AXHzo1oBdosnxbT47ZJEZ9WXn050X8Re6wZ1vaNnTpB/l2fMQt90evT7RBK0fB8UjXQUDMKyd7bbiqg=="], "openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="], - "opentui-spinner": ["opentui-spinner@0.0.6", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.1.49", "@opentui/react": "^0.1.49", "@opentui/solid": "^0.1.49", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-xupLOeVQEAXEvVJCvHkfX6fChDWmJIPHe5jyUrVb8+n4XVTX8mBNhitFfB9v2ZbkC1H2UwPab/ElePHoW37NcA=="], + "opentui-spinner": ["opentui-spinner@0.0.7", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.3.4", "@opentui/react": "^0.3.4", "@opentui/solid": "^0.3.4", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-nPzwAvJG+y9rVEwwHLHqbsMzLnIk2zw+F9LqwA7aYJvpM5gsrKC2rrGi36A+tZpA+1RnWxXeWEgVZMchnaH18Q=="], "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], @@ -4488,6 +4747,8 @@ "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], + "pkijs": ["pkijs@3.4.0", "", { "dependencies": { "@noble/hashes": "1.4.0", "asn1js": "^3.0.6", "bytestreamjs": "^2.0.1", "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw=="], + "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], @@ -4520,6 +4781,10 @@ "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + "preact": ["preact@11.0.0-beta.0", "", {}, "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg=="], + + "preact-render-to-string": ["preact-render-to-string@6.6.5", "", { "peerDependencies": { "preact": ">=10 || >= 11.0.0-0" } }, "sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA=="], + "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], "pretty": ["pretty@2.0.0", "", { "dependencies": { "condense-newlines": "^0.2.1", "extend-shallow": "^2.0.1", "js-beautify": "^1.6.12" } }, "sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w=="], @@ -4570,6 +4835,10 @@ "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], + "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], @@ -4798,7 +5067,7 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="], + "shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="], "shikiji": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="], @@ -4840,18 +5109,26 @@ "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + "solid-dismissible": ["solid-dismissible@0.1.1", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-9kcKBJIMdS+586cA1g63HYWxKh3h89leeNHbPZ1csYjuni+NvPBtNr11l0iEX2AKKEt6FHk6qNhc/gjoYAW1pA=="], + + "solid-focus-trap": ["solid-focus-trap@0.1.9", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-LTyNki6GUJPRLXV5uMWPkYClB07SUMubbr2EkAddiR0CJCF/I283txilMU9RURSr/P8EewMfXWu2o3aWrK7A5A=="], + "solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="], "solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="], - "solid-presence": ["solid-presence@0.1.8", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA=="], + "solid-presence": ["solid-presence@0.2.0", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-YM92o+jvpzX3XGaD4rLYmq/Kc2ZVh47GSCLEufHBFQQIurvZTs8SoGJxO8BJGNDxBKdcS8F3dYhW1SDXp4BNjA=="], "solid-prevent-scroll": ["solid-prevent-scroll@0.1.10", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-KplGPX2GHiWJLZ6AXYRql4M127PdYzfwvLJJXMkO+CMb8Np4VxqDAg5S8jLdwlEuBis/ia9DKw2M8dFx5u8Mhw=="], "solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="], + "solid-sonner": ["solid-sonner@0.3.1", "", { "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-F/+zi9yKJTHh5hX1UGJfkDvyC+F34Vi3jgy44NJwOKCgic1QtAon0b1iT9OsDO77RTgR+PCil+3Y5B8T2Owy1Q=="], + "solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="], + "solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="], + "solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="], "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], @@ -4976,6 +5253,8 @@ "system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + "tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], @@ -5072,22 +5351,12 @@ "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], - "turbo": ["turbo@2.8.13", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.13", "turbo-darwin-arm64": "2.8.13", "turbo-linux-64": "2.8.13", "turbo-linux-arm64": "2.8.13", "turbo-windows-64": "2.8.13", "turbo-windows-arm64": "2.8.13" }, "bin": { "turbo": "bin/turbo" } }, "sha512-nyM99hwFB9/DHaFyKEqatdayGjsMNYsQ/XBNO6MITc7roncZetKb97MpHxWf3uiU+LB9c9HUlU3Jp2Ixei2k1A=="], - - "turbo-darwin-64": ["turbo-darwin-64@2.8.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-PmOvodQNiOj77+Zwoqku70vwVjKzL34RTNxxoARjp5RU5FOj/CGiC6vcDQhNtFPUOWSAaogHF5qIka9TBhX4XA=="], - - "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kI+anKcLIM4L8h+NsM7mtAUpElkCOxv5LgiQVQR8BASyDFfc8Efj5kCk3cqxuxOvIqx0sLfCX7atrHQ2kwuNJQ=="], - - "turbo-linux-64": ["turbo-linux-64@2.8.13", "", { "os": "linux", "cpu": "x64" }, "sha512-j29KnQhHyzdzgCykBFeBqUPS4Wj7lWMnZ8CHqytlYDap4Jy70l4RNG46pOL9+lGu6DepK2s1rE86zQfo0IOdPw=="], - - "turbo-linux-arm64": ["turbo-linux-arm64@2.8.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-OEl1YocXGZDRDh28doOUn49QwNe82kXljO1HXApjU0LapkDiGpfl3jkAlPKxEkGDSYWc8MH5Ll8S16Rf5tEBYg=="], - - "turbo-windows-64": ["turbo-windows-64@2.8.13", "", { "os": "win32", "cpu": "x64" }, "sha512-717bVk1+Pn2Jody7OmWludhEirEe0okoj1NpRbSm5kVZz/yNN/jfjbxWC6ilimXMz7xoMT3IDfQFJsFR3PMANA=="], - - "turbo-windows-arm64": ["turbo-windows-arm64@2.8.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-R819HShLIT0Wj6zWVnIsYvSNtRNj1q9VIyaUz0P24SMcLCbQZIm1sV09F4SDbg+KCCumqD2lcaR2UViQ8SnUJA=="], + "turbo": ["turbo@2.10.2", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.2", "@turbo/darwin-arm64": "2.10.2", "@turbo/linux-64": "2.10.2", "@turbo/linux-arm64": "2.10.2", "@turbo/windows-64": "2.10.2", "@turbo/windows-arm64": "2.10.2" }, "bin": { "turbo": "bin/turbo" } }, "sha512-wTExrNrRjB8qzIcg+ZLm0A3GFNLDsWNwdS/RBXB0FPrBDyzk3i96Yx+TxWZC7a0k1SIreFB8ciUbxjmEqTH8IQ=="], "turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="], + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + "tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="], "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], @@ -5170,6 +5439,8 @@ "unzip-stream": ["unzip-stream@0.3.4", "", { "dependencies": { "binary": "^0.3.0", "mkdirp": "^0.5.1" } }, "sha512-PyofABPVv+d7fL7GOpusx7eRT9YETY2X04PhwbSipdj6bMxVCFJrr+nm0Mxqbf9hUiTin/UsnuFWBXlDZFy0Cw=="], + "unzipper": ["unzipper@0.12.3", "", { "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", "fs-extra": "^11.2.0", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } }, "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], @@ -5194,7 +5465,7 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.0.2", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.47", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.27" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-aoa05nI3BTK5aGbjBflq+Gfln2AHAkwNbWuGGvCzUIsOfp5Y3iPD4O4PUGDAEiWVJWbjpPn0KfDa0H/HebwsaA=="], + "venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.1.1", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.51", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.30" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-w3OHkuzzKZ3r2TOxER6myBYzZJNoDqol+DUHu3NnfBN/GETnUVxecZJab0CHQQ8GZc0jjzpFymepjcLDPS4SQg=="], "verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="], @@ -5204,8 +5475,6 @@ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - "virtua": ["virtua@0.49.1", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-6f79msqg3jzNFdqJiS0FSzhRN1EHlDhR7EvW7emp6z5qQ22VdsReiDHflkpMEMhoAyUuYr69nwT0aagiM7NrUg=="], - "vite": ["vite@7.1.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw=="], "vite-plugin-dynamic-import": ["vite-plugin-dynamic-import@1.6.0", "", { "dependencies": { "acorn": "^8.12.1", "es-module-lexer": "^1.5.4", "fast-glob": "^3.3.2", "magic-string": "^0.30.11" } }, "sha512-TM0sz70wfzTIo9YCxVFwS8OA9lNREsh+0vMHGSkWDTZ7bgd1Yjs5RV8EgB634l/91IsXJReg0xtmuQqP0mf+rg=="], @@ -5260,6 +5529,8 @@ "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], + "webcrypto-core": ["webcrypto-core@1.9.2", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], "webpack-sources": ["webpack-sources@3.5.0", "", {}, "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ=="], @@ -5338,8 +5609,6 @@ "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], - "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], - "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], @@ -5384,33 +5653,39 @@ "@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], - "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], + "@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + + "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + + "@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], - "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], + "@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + + "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], "@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/cohere/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/deepgram/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + "@ai-sdk/deepgram/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], - "@ai-sdk/deepgram/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "@ai-sdk/deepgram/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7u5B/E2uZmU65SlJhhQGFHZwRCN0xOz4HHtFc4sEGV9PHbX3fGiEiZBpc/SABay1dGeJgK3VD60rvLGoWdWPXA=="], "@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], - "@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + "@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], - "@ai-sdk/elevenlabs/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "@ai-sdk/elevenlabs/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7u5B/E2uZmU65SlJhhQGFHZwRCN0xOz4HHtFc4sEGV9PHbX3fGiEiZBpc/SABay1dGeJgK3VD60rvLGoWdWPXA=="], - "@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z9MC6M4Oh/yUY/F/eszOtO8wc2nMz99XmZQKd2gWTtyIfe716xTfrKe3aYZKg20NZDtyjqPPKPSR+wqz7q1T7Q=="], + "@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-glcEJC2mBXJKj7joFI0fRhcbdDYKTBgXMPcT6Vcnlym67tTzuNG9pFx3zblxVv8TdOxhojJja5zGG19yeGJxuA=="], - "@ai-sdk/fireworks/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + "@ai-sdk/fireworks/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], - "@ai-sdk/fireworks/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "@ai-sdk/fireworks/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7u5B/E2uZmU65SlJhhQGFHZwRCN0xOz4HHtFc4sEGV9PHbX3fGiEiZBpc/SABay1dGeJgK3VD60rvLGoWdWPXA=="], "@ai-sdk/google/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], @@ -5426,7 +5701,9 @@ "@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], + "@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + + "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="], "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], @@ -5440,7 +5717,11 @@ "@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], + "@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cQrN6OUn/jvsY3OdsU6Wn+ss7vp1iwIcakZKSlSRMnYqShBfyT7Qht+eqmgxs7w9ttrw6FAG6o11AiBs+iEsTA=="], + + "@ai-sdk/xai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.13", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-ZPtVYt5QIJzOta1kdUiDuCx4HhFkvNPv/rvmZ2b1iXwybYjJsCnNYR4PAw4kW7rgVfDARvHXcU64efWuqNp6bw=="], + + "@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bjYld/2KGPLt78kpqbya+fD4LYS7BqVQJyUjE3qAHrYB0FR2Q90BaWEVIBZaguTWXf/A8L6uG1zO1v9TxVlGWg=="], "@astrojs/check/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -5450,8 +5731,12 @@ "@astrojs/markdown-remark/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "@astrojs/markdown-remark/shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="], + "@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="], + "@astrojs/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "@astrojs/sitemap/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], @@ -5626,9 +5911,7 @@ "@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], - "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], - - "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@electron/get/undici": ["undici@7.26.0", "", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="], "@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], @@ -5662,8 +5945,12 @@ "@jsx-email/doiuse-email/htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="], + "@kobalte/core/solid-presence": ["solid-presence@0.1.8", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA=="], + "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + "@mdx-js/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], @@ -5742,15 +6029,15 @@ "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], - "@opencode-ai/core/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="], + "@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="], - "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], + "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], "@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], - "@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="], + "@opencode-ai/core/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="], "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], @@ -5758,7 +6045,11 @@ "@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], - "@opencode-ai/tui/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="], + "@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="], + + "@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], "@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], @@ -5766,6 +6057,8 @@ "@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + "@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], @@ -5778,8 +6071,6 @@ "@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], - "@poppinss/dumper/@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], - "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], "@protobuf-ts/plugin/typescript": ["typescript@3.9.10", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q=="], @@ -5796,13 +6087,17 @@ "@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], + + "@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], - "@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@shikijs/langs/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], - "@shikijs/langs/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@shikijs/primitive/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], - "@shikijs/themes/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@shikijs/stream/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="], + + "@shikijs/themes/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], "@slack/bolt/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], @@ -5828,6 +6123,10 @@ "@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="], + "@standard-community/standard-json/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], + + "@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], + "@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], "@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -5856,6 +6155,10 @@ "@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + "@vitest/coverage-v8/@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="], + + "@vitest/coverage-v8/magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], + "@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], "@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], @@ -5868,13 +6171,25 @@ "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], - "ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.107", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.78", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8nT08pGPy25rleJNk56ep00UHK6kCtCmu+ZNqVVSSPDieADlIZqcaN1iRXAFBoCH0Fb9F6C2EjFDaySdsargfQ=="], + "ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.153", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.110", "@ai-sdk/openai": "3.0.96", "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iEXrLgWylCHJmznqlKLU3CqRh8UWibv+illrwmsk136FVBBvyXiGnpQrI1pGWCScVLQjBQSFQu7GJDkUEomf/A=="], + + "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.110", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rNkamQCeAUOUGr5Npg5pXZyYFH4fS1U6Mbdy3dF/NNBEI3D2Chc/ruRrwNegP0gfpX3cllP3O4jSibGBbWPZ7A=="], + + "ai-gateway-provider/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="], + + "ai-gateway-provider/@ai-sdk/cohere": ["@ai-sdk/cohere@3.0.54", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cXLjIsSzUriPHe704IH6d+ipJ/OvczTB700p9Zma7DPgQzvxG/diyr8q/2LEsbTRiTopiKhky8dn1PJNQcJToQ=="], - "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="], + "ai-gateway-provider/@ai-sdk/google": ["@ai-sdk/google@3.0.108", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kwvYpRNghqt0VRKE7Hx1UWZQCUJJFqUITj24baxy+ApS0Hru0PkBJHD75a36Wc+e6e+wHcKR2MconTeJiBZigA=="], - "ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], + "ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.181", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.110", "@ai-sdk/google": "3.0.108", "@ai-sdk/openai-compatible": "2.0.67", "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-57b5Qor8V53vubkxCj09tbHWpzpCLUbzmll2FwShuLvyEAsCH6mh3sAowDhiwUWPXnLzU+rC3RVMKCPscqICcg=="], - "ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.8.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Y6j3yivgoEUf/kutD/k5GX/mzZfioRFoSx0gbQ+mIOzMaH/vJv1rCkztiuvlLw5xRYQil7oxHUZvmSfXqOx1NQ=="], + "ai-gateway-provider/@ai-sdk/groq": ["@ai-sdk/groq@3.0.59", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-X4h60TGq4pIOXPsthatUr+bfTaYCaKGX597hG9JgcueEl4+nboCdw99ixjFKGkvYlBJwLCCfI957EmGA2QlF0w=="], + + "ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], + + "ai-gateway-provider/@ai-sdk/perplexity": ["@ai-sdk/perplexity@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bL3SWrPltTuxVNg/bZ5APbJLVe0+BIU+BYAbT8Yo0eSAZ5eMTImN2murQHZa08Wi8lUMm8MNolaYBQkb0JDbFw=="], + + "ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.10.0", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-FMsAEjLUt5pWuRE2LDC/LCvVrFjLlrEzUITH5+5SZtfq7KZ2wrOHjQVxzz92sju8S9ltpzW87CLW8/b0oBXVCw=="], "ajv-keywords/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], @@ -5898,14 +6213,20 @@ "archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + "ast-v8-to-istanbul/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], + "astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="], + "astro/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="], "astro/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="], "astro/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "astro/shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="], + "astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], "astro/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], @@ -5936,9 +6257,7 @@ "condense-newlines/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="], - "conf/dot-prop": ["dot-prop@9.0.0", "", { "dependencies": { "type-fest": "^4.18.2" } }, "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ=="], - - "conf/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], + "conf/dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="], "config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], @@ -5950,8 +6269,6 @@ "dir-compare/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], - "dmg-builder/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "dmg-builder/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "dmg-license/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], @@ -5960,6 +6277,8 @@ "dot-prop/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="], + "duplexer2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "editorconfig/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], "editorconfig/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -5970,18 +6289,28 @@ "electron-builder/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + "electron-builder-squirrel-windows/app-builder-lib": ["app-builder-lib@26.8.1", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.8.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.8.1", "electron-builder-squirrel-windows": "26.8.1" } }, "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw=="], + + "electron-builder-squirrel-windows/builder-util": ["builder-util@26.8.1", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw=="], + "electron-publish/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], + "electron-store/type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], + "electron-updater/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "electron-updater/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], + "encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], + "esast-util-from-js/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], @@ -6016,6 +6345,8 @@ "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "got/@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], + "html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], "html-minifier-terser/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -6026,6 +6357,8 @@ "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], + "istanbul-reports/html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="], @@ -6042,6 +6375,8 @@ "md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="], + "micromark-extension-mdxjs/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], @@ -6062,6 +6397,8 @@ "nitro/undici": ["undici@7.26.0", "", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="], + "node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + "node-gyp/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="], "node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -6072,9 +6409,9 @@ "nypm/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], - "opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="], + "opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="], - "opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], + "opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], @@ -6082,6 +6419,10 @@ "opencode/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], + "opencode/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], "openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], @@ -6098,6 +6439,8 @@ "pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], + "pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], @@ -6136,14 +6479,16 @@ "sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], + "shiki/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="], - "shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], + "solid-transition-size/@corvu/utils": ["@corvu/utils@0.3.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.7" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ZWlyWEE8qV9+CB9OAyo2bTrZGXQN9ZeM+JfYv89zoR+lRACKTDuoOZEdiyL8Uc7U5dUSH1uTqKhTTnaHWb+wZA=="], + "sort-keys/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="], "sst/aws4fetch": ["aws4fetch@1.0.18", "", {}, "sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ=="], @@ -6162,6 +6507,8 @@ "tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "terser/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], @@ -6180,15 +6527,23 @@ "unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], + "unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "unplugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], - "venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="], + "unzipper/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], + + "venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.32" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SoPSkrL5cbNQnAljRsJ7pOzJ2FmWgnhC0lfFOda873ycCdFJL1A+h3Ib7mX2spcv3XnNaO13y/45/0RyqNWlIQ=="], "venice-ai-sdk-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], - "venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.32", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Kwj499fTcN9bP/AfGoPU7JWIXeP6VZqKI6omsH062c9E2G4gdjeJczkz4z/tYSkzYjLE2AI3DtZbMfs6D7vn2Q=="], + + "verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], + + "vite-plugin-dynamic-import/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], @@ -6244,14 +6599,20 @@ "@ai-sdk/deepgram/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@ai-sdk/deepgram/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "@ai-sdk/deepinfra/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@ai-sdk/elevenlabs/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@ai-sdk/elevenlabs/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "@ai-sdk/fireworks/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@ai-sdk/fireworks/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "@ai-sdk/google-vertex/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@ai-sdk/google/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -6270,12 +6631,26 @@ "@ai-sdk/vercel/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@ai-sdk/xai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@astrojs/check/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "@astrojs/check/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "@astrojs/markdown-remark/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@astrojs/markdown-remark/shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], + + "@astrojs/markdown-remark/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="], + + "@astrojs/markdown-remark/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="], + + "@astrojs/markdown-remark/shiki/@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="], + + "@astrojs/markdown-remark/shiki/@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="], + + "@astrojs/markdown-remark/shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@astrojs/mdx/@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.6", "", {}, "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q=="], "@astrojs/mdx/@astrojs/markdown-remark/@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="], @@ -6346,8 +6721,6 @@ "@electron/fuses/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], - "@electron/notarize/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], "@electron/universal/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], @@ -6552,6 +6925,10 @@ "@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], + "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + + "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], + "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], "@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], @@ -6574,6 +6951,8 @@ "@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "@shikijs/stream/@shikijs/core/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], + "@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], @@ -6592,25 +6971,67 @@ "@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], + "@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@storybook/csf-plugin/unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@vitest/coverage-v8/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="], + "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + "ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/openai": ["@ai-sdk/openai@3.0.96", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Pex8vOj1y05j7jtBS39cJJRDjJbMIyCY9+01cSIp1hwEJTKImrFejMgsAazMWXSi/HU+B9ZE6ElftCOwvg4mmQ=="], + + "ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], - "ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7u5B/E2uZmU65SlJhhQGFHZwRCN0xOz4HHtFc4sEGV9PHbX3fGiEiZBpc/SABay1dGeJgK3VD60rvLGoWdWPXA=="], "ai-gateway-provider/@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="], "ai-gateway-provider/@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], - "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], + + "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7u5B/E2uZmU65SlJhhQGFHZwRCN0xOz4HHtFc4sEGV9PHbX3fGiEiZBpc/SABay1dGeJgK3VD60rvLGoWdWPXA=="], + + "ai-gateway-provider/@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.54", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OyXt0zK8y2/ZIyWlbxTv2r1M7AK227S+Gl4BYOEF42q0wz1n5m4fwR8L4Fy/MQ4Ho6xje47MPsFcRdIqIyP6Rw=="], + + "ai-gateway-provider/@ai-sdk/cerebras/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="], + + "ai-gateway-provider/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="], + + "ai-gateway-provider/@ai-sdk/cohere/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], + + "ai-gateway-provider/@ai-sdk/cohere/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7u5B/E2uZmU65SlJhhQGFHZwRCN0xOz4HHtFc4sEGV9PHbX3fGiEiZBpc/SABay1dGeJgK3VD60rvLGoWdWPXA=="], + + "ai-gateway-provider/@ai-sdk/google/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], + + "ai-gateway-provider/@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7u5B/E2uZmU65SlJhhQGFHZwRCN0xOz4HHtFc4sEGV9PHbX3fGiEiZBpc/SABay1dGeJgK3VD60rvLGoWdWPXA=="], + + "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-glcEJC2mBXJKj7joFI0fRhcbdDYKTBgXMPcT6Vcnlym67tTzuNG9pFx3zblxVv8TdOxhojJja5zGG19yeGJxuA=="], + + "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], + + "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7u5B/E2uZmU65SlJhhQGFHZwRCN0xOz4HHtFc4sEGV9PHbX3fGiEiZBpc/SABay1dGeJgK3VD60rvLGoWdWPXA=="], + + "ai-gateway-provider/@ai-sdk/groq/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], + + "ai-gateway-provider/@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7u5B/E2uZmU65SlJhhQGFHZwRCN0xOz4HHtFc4sEGV9PHbX3fGiEiZBpc/SABay1dGeJgK3VD60rvLGoWdWPXA=="], - "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + + "ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], + + "ai-gateway-provider/@ai-sdk/perplexity/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], + + "ai-gateway-provider/@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7u5B/E2uZmU65SlJhhQGFHZwRCN0xOz4HHtFc4sEGV9PHbX3fGiEiZBpc/SABay1dGeJgK3VD60rvLGoWdWPXA=="], "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], @@ -6618,6 +7039,8 @@ "ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + "app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], "app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -6636,6 +7059,18 @@ "astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "astro/shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], + + "astro/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="], + + "astro/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="], + + "astro/shiki/@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="], + + "astro/shiki/@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="], + + "astro/shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "astro/unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "astro/unstorage/h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], @@ -6656,6 +7091,8 @@ "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "conf/dot-prop/type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "dir-compare/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], @@ -6666,8 +7103,38 @@ "dmg-license/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "duplexer2/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "duplexer2/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "duplexer2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "editorconfig/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], + "electron-builder-squirrel-windows/app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="], + + "electron-builder-squirrel-windows/app-builder-lib/builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], + + "electron-builder-squirrel-windows/app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], + + "electron-builder-squirrel-windows/app-builder-lib/dmg-builder": ["dmg-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg=="], + + "electron-builder-squirrel-windows/app-builder-lib/electron-publish": ["electron-publish@26.8.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w=="], + + "electron-builder-squirrel-windows/app-builder-lib/hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], + + "electron-builder-squirrel-windows/app-builder-lib/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "electron-builder-squirrel-windows/app-builder-lib/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "electron-builder-squirrel-windows/app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], + + "electron-builder-squirrel-windows/builder-util/builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], + + "electron-builder-squirrel-windows/builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "electron-builder-squirrel-windows/builder-util/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "electron-builder/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "electron-builder/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -6698,8 +7165,6 @@ "js-beautify/nopt/abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="], - "lazystream/readable-stream/core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], - "lazystream/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], "lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], @@ -6710,6 +7175,18 @@ "motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], + "opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + + "opencode/@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.54", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OyXt0zK8y2/ZIyWlbxTv2r1M7AK227S+Gl4BYOEF42q0wz1n5m4fwR8L4Fy/MQ4Ho6xje47MPsFcRdIqIyP6Rw=="], + + "opencode/@ai-sdk/cerebras/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="], + + "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="], + + "opencode/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + + "opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], + "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], @@ -6740,6 +7217,12 @@ "unplugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + + "venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="], + + "venice-ai-sdk-provider/@ai-sdk/provider-utils/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="], + "venice-ai-sdk-provider/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -6952,6 +7435,8 @@ "@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], @@ -6966,8 +7451,36 @@ "ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + + "ai-gateway-provider/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "ai-gateway-provider/@ai-sdk/cohere/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "ai-gateway-provider/@ai-sdk/cohere/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + + "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + + "ai-gateway-provider/@ai-sdk/google/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "ai-gateway-provider/@ai-sdk/google/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + + "ai-gateway-provider/@ai-sdk/groq/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "ai-gateway-provider/@ai-sdk/groq/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + + "ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "ai-gateway-provider/@ai-sdk/perplexity/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "ai-gateway-provider/@ai-sdk/perplexity/@ai-sdk/provider-utils/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], @@ -6994,6 +7507,26 @@ "editorconfig/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "electron-builder-squirrel-windows/app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "electron-builder-squirrel-windows/app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + + "electron-builder-squirrel-windows/app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "electron-builder-squirrel-windows/app-builder-lib/dmg-builder/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "electron-builder-squirrel-windows/app-builder-lib/electron-publish/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "electron-builder-squirrel-windows/app-builder-lib/electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], + + "electron-builder-squirrel-windows/app-builder-lib/hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + + "electron-builder-squirrel-windows/app-builder-lib/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "electron-builder-squirrel-windows/app-builder-lib/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + + "electron-builder-squirrel-windows/builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "electron-builder/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "electron-builder/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -7016,6 +7549,10 @@ "js-beautify/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "opencode/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], @@ -7080,6 +7617,8 @@ "babel-plugin-module-resolver/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "electron-builder-squirrel-windows/app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + "electron-builder/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "electron-builder/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/bunfig.toml b/bunfig.toml index 283e9fbfe7f0..c506ff57c4bf 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,7 +2,7 @@ exact = true # Only install newly resolved package versions published at least 3 days ago. minimumReleaseAge = 259200 -minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "gitlab-ai-provider", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64"] +minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"] [test] root = "./do-not-run-tests-from-root" diff --git a/infra/console.ts b/infra/console.ts index 0a304a7be309..79556f5e0c7c 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -256,6 +256,7 @@ new sst.cloudflare.x.SolidStart("Console", { SECRET.UpstashRedisRestToken, AUTH_API_URL, STRIPE_WEBHOOK_SECRET, + SECRET.SupportApiKey, DISCORD_INCIDENT_WEBHOOK_URL, SECRET.HoneycombWebhookSecret, STRIPE_SECRET_KEY, @@ -287,8 +288,12 @@ new sst.cloudflare.x.SolidStart("Console", { server: { placement: { region: "aws:us-east-2" }, transform: { - worker: { - tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }], + worker: (args) => { + args.compatibilityFlags = $resolve(args.compatibilityFlags).apply((flags) => [ + ...(flags ?? []), + "global_fetch_strictly_public", + ]) + args.tailConsumers = [{ service: logProcessor.nodes.worker.scriptName }] }, }, }, diff --git a/infra/enterprise.ts b/infra/enterprise.ts index dc336a68431b..cd2a96d9b31e 100644 --- a/infra/enterprise.ts +++ b/infra/enterprise.ts @@ -7,6 +7,7 @@ new sst.cloudflare.x.SolidStart("Teams", { domain: shortDomain, path: "packages/enterprise", buildCommand: "bun run build:cloudflare", + link: [SECRET.SupportApiKey], environment: { OPENCODE_STORAGE_ADAPTER: "r2", OPENCODE_STORAGE_ACCOUNT_ID: sst.cloudflare.DEFAULT_ACCOUNT_ID, diff --git a/infra/lake.ts b/infra/lake.ts index dd11ca38f977..d4e46168e366 100644 --- a/infra/lake.ts +++ b/infra/lake.ts @@ -67,6 +67,10 @@ const athenaWorkgroup = new aws.athena.Workgroup("LakeAthenaWorkgroup", { configuration: { enforceWorkgroupConfiguration: true, publishCloudwatchMetricsEnabled: true, + // Athena bills $5/TB scanned; kill any query that would scan more than 2 TB + // so a regression cannot silently burn money. Stats sync full passes scan + // ~250 GB as of 2026-07. + bytesScannedCutoffPerQuery: 2 * 1024 ** 4, resultConfiguration: { outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`, }, diff --git a/infra/secret.ts b/infra/secret.ts index 65ada2f1f64d..2df3e6077648 100644 --- a/infra/secret.ts +++ b/infra/secret.ts @@ -9,6 +9,7 @@ export const SECRET = { R2SecretKey: new sst.Secret("R2SecretKey", "unknown"), HoneycombApiKey: new sst.Secret("HONEYCOMB_API_KEY"), HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }), + SupportApiKey: new sst.Secret("SUPPORT_API_KEY"), UpstashRedisRestUrl: new sst.Secret("UpstashRedisRestUrl"), UpstashRedisRestToken: new sst.Secret("UpstashRedisRestToken"), } diff --git a/infra/stage.ts b/infra/stage.ts index 8d80eefed880..f0db79744851 100644 --- a/infra/stage.ts +++ b/infra/stage.ts @@ -8,6 +8,25 @@ export const zoneID = "430ba34c138cfb5360826c4909f99be8" export const awsStage = $app.stage === "production" ? "production" : "dev" export const deployAws = $app.stage === awsStage +if ($app.stage === "production") { + new cloudflare.DnsRecord("TrustCenter", { + zoneId: zoneID, + name: "trust.opencode.ai", + type: "CNAME", + content: "3a69a5bb27875189.vercel-dns-016.com", + proxied: false, + ttl: 60, + }) + + new cloudflare.DnsRecord("TrustCenterVerification", { + zoneId: zoneID, + name: "opencode.ai", + type: "TXT", + content: "compai-domain-verification=org_6993a99c6200a2d642bb115d", + ttl: 60, + }) +} + new cloudflare.RegionalHostname("RegionalHostname", { hostname: domain, regionKey: "us", diff --git a/infra/stats.ts b/infra/stats.ts index 67387ee5a86d..29c9537daf88 100644 --- a/infra/stats.ts +++ b/infra/stats.ts @@ -42,11 +42,12 @@ const inferenceEventTable = new aws.s3tables.Table( { name: "request", type: "string", required: false }, { name: "client", type: "string", required: false }, { name: "user_agent", type: "string", required: false }, + { name: "model", type: "string", required: false }, + { name: "model_tier", type: "string", required: false }, { name: "model_variant", type: "string", required: false }, { name: "source", type: "string", required: false }, { name: "provider", type: "string", required: false }, { name: "provider_model", type: "string", required: false }, - { name: "model", type: "string", required: false }, { name: "llm_error_code", type: "int", required: false }, { name: "llm_error_message", type: "string", required: false }, { name: "error_response", type: "string", required: false }, @@ -56,6 +57,7 @@ const inferenceEventTable = new aws.s3tables.Table( { name: "error_cause2", type: "string", required: false }, { name: "api_key", type: "string", required: false }, { name: "workspace", type: "string", required: false }, + { name: "user_id", type: "string", required: false }, { name: "is_subscription", type: "boolean", required: false }, { name: "subscription", type: "string", required: false }, { name: "response_length", type: "long", required: false }, @@ -84,7 +86,7 @@ const inferenceEventTable = new aws.s3tables.Table( }, }, }, - { deleteBeforeReplace: $app.stage !== "production" }, + { deleteBeforeReplace: $app.stage !== "production", ignoreChanges: ["metadata"] }, ) export const inferenceEvent = new sst.Linkable("InferenceEvent", { @@ -165,7 +167,7 @@ export const app = new sst.cloudflare.x.SolidStart("Stats", { domain: `stats.${domain}`, link: [database, EMAILOCTOPUS_API_KEY], environment: { - PUBLIC_URL: `https://${domain}/stats`, + PUBLIC_URL: `https://${domain}/data`, }, }) @@ -179,17 +181,31 @@ const statsSyncConfig = new sst.Linkable("StatsSyncConfig", { }, }) +const r2SqlAuthToken = new sst.Secret("R2SqlAuthToken") +const r2Sql = new sst.Linkable("R2Sql", { + properties: { + accountId: "15d29c8639fd3733b1b5486a2acfd968", + bucket: `platform-${$app.stage}-lake`, + namespace: "inference", + table: "generation", + }, +}) + export const statSync = new sst.aws.Service("StatsSyncService", { cluster: lakeCluster, architecture: "arm64", cpu: "0.25 vCPU", - memory: "0.5 GB", + // 0.5 GB caused an OOM crash loop: every restart immediately re-ran the 4 Athena + // stats queries (~$5/pass) every ~5 minutes instead of hourly. + memory: "2 GB", image: { context: ".", dockerfile: "packages/stats/server/Dockerfile", }, command: ["bun", "src/stat-sync.ts"], - link: [database, inferenceEvent, statsSyncConfig], + // Keep the legacy Athena link and IAM permissions during the first R2-backed + // release so reverting the application code remains a one-deploy rollback. + link: [database, inferenceEvent, r2Sql, r2SqlAuthToken, statsSyncConfig], permissions: lakeQueryPermissions, scaling: { min: 1, diff --git a/nix/desktop.nix b/nix/desktop.nix index d0d7fa7eca1f..2df62f7a1c76 100644 --- a/nix/desktop.nix +++ b/nix/desktop.nix @@ -8,6 +8,8 @@ makeWrapper, writableTmpDirAsHomeHook, autoPatchelfHook, + copyDesktopItems, + makeDesktopItem, opencode, }: let @@ -27,9 +29,12 @@ stdenv.mkDerivation (finalAttrs: { nodejs makeWrapper writableTmpDirAsHomeHook - ] ++ lib.optionals stdenv.hostPlatform.isLinux [ + ] + ++ lib.optionals stdenv.hostPlatform.isLinux [ autoPatchelfHook - ] ++ lib.optionals stdenv.hostPlatform.isDarwin [ + copyDesktopItems + ] + ++ lib.optionals stdenv.hostPlatform.isDarwin [ # Ad-hoc sign the .app: --config.mac.identity=null below skips signing. darwin.autoSignDarwinBinariesHook ]; @@ -38,20 +43,37 @@ stdenv.mkDerivation (finalAttrs: { (lib.getLib stdenv.cc.cc) ]; + desktopItems = lib.optional stdenv.hostPlatform.isLinux (makeDesktopItem { + name = "ai.opencode.desktop"; + desktopName = "OpenCode"; + exec = "opencode-desktop %U"; + icon = "ai.opencode.desktop"; + # Electron 41 derives X11 WM_CLASS from app.name. + startupWMClass = "OpenCode"; + categories = [ "Development" ]; + }); + env = opencode.env // { ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; }; - # https://github.com/electron/electron/issues/31121 - # mac builds use a .app bundle which doesnt have this issue - postPatch = lib.optionalString stdenv.isLinux '' - BASE_PATH=packages/desktop - FILES=(src/main/windows.ts) - for file in "''${FILES[@]}"; do - substituteInPlace $BASE_PATH/$file \ - --replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'" - done - ''; + postPatch = + # NOTE: Relax Bun version check to be a warning instead of an error + '' + substituteInPlace packages/script/src/index.ts \ + --replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \ + 'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}' + '' + # https://github.com/electron/electron/issues/31121 + # mac builds use a .app bundle which doesnt have this issue + + lib.optionalString stdenv.isLinux '' + BASE_PATH=packages/desktop + FILES=(src/main/windows.ts) + for file in "''${FILES[@]}"; do + substituteInPlace $BASE_PATH/$file \ + --replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'" + done + ''; preBuild = '' cp -r "${electron.dist}" $HOME/.electron-dist @@ -76,27 +98,38 @@ stdenv.mkDerivation (finalAttrs: { runHook postBuild ''; - installPhase = - '' - runHook preInstall - '' - + lib.optionalString stdenv.hostPlatform.isDarwin '' - mkdir -p $out/Applications - mv dist/mac*/*.app $out/Applications - makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop - '' - + lib.optionalString stdenv.hostPlatform.isLinux '' - mkdir -p $out/opt/opencode-desktop - cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop - makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \ - --inherit-argv0 \ - --set ELECTRON_FORCE_IS_PACKAGED 1 \ - --add-flags $out/opt/opencode-desktop/resources/app.asar \ - --add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}" - '' - + '' - runHook postInstall - ''; + installPhase = '' + runHook preInstall + '' + + lib.optionalString stdenv.hostPlatform.isDarwin '' + mkdir -p $out/Applications + mv dist/mac*/*.app $out/Applications + makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop + '' + + lib.optionalString stdenv.hostPlatform.isLinux '' + mkdir -p $out/opt/opencode-desktop + cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop + install -Dm644 resources/icons/32x32.png \ + "$out/share/icons/hicolor/32x32/apps/ai.opencode.desktop.png" + install -Dm644 resources/icons/64x64.png \ + "$out/share/icons/hicolor/64x64/apps/ai.opencode.desktop.png" + install -Dm644 resources/icons/128x128.png \ + "$out/share/icons/hicolor/128x128/apps/ai.opencode.desktop.png" + install -Dm644 resources/icons/128x128@2x.png \ + "$out/share/icons/hicolor/256x256/apps/ai.opencode.desktop.png" + install -Dm644 resources/icons/icon.png \ + "$out/share/icons/hicolor/512x512/apps/ai.opencode.desktop.png" + install -Dm644 resources/ai.opencode.desktop.metainfo.xml \ + "$out/share/metainfo/ai.opencode.desktop.metainfo.xml" + makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \ + --inherit-argv0 \ + --set ELECTRON_FORCE_IS_PACKAGED 1 \ + --add-flags $out/opt/opencode-desktop/resources/app.asar \ + --add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}" + '' + + '' + runHook postInstall + ''; autoPatchelfIgnoreMissingDeps = [ "libc.musl-x86_64.so.1" diff --git a/nix/hashes.json b/nix/hashes.json index bf6481d85fa2..88d8be13d238 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-1tKRDDKUF+no53SwpTBB+cc81gF/shaaFkUwBmUX7Z8=", - "aarch64-linux": "sha256-jy1c4qRmRWVwwPcmvzUmPH9YNN/N0D/g17GROkgAe2M=", - "aarch64-darwin": "sha256-HwN574uks3OweyGtCFloSjHixdNRzCUEqrMZESPARmw=", - "x86_64-darwin": "sha256-qccXjybWIhbCkxCfnwkpWIZ7riirjGKNUpzHQnjRRzc=" + "x86_64-linux": "sha256-kDCnJMnaK/Jq7ckcpPB7Vl9v98EMSdcehZAtf8jNjTs=", + "aarch64-linux": "sha256-0aR+OJGXS5HMlXbe/BHybjIRvdNJJw6gjW+jr6Dk7Pk=", + "aarch64-darwin": "sha256-loLrV6xiorhwS/N2hlpiKSKX172Qxy+auNiPzFBhQSc=", + "x86_64-darwin": "sha256-PNEpQBLAz8M274bSyTpp0jofETn2L+D0uBiJHUV7nB0=" } } diff --git a/nix/opencode.nix b/nix/opencode.nix index 82a7b54c4048..a22f7d3d2473 100644 --- a/nix/opencode.nix +++ b/nix/opencode.nix @@ -27,6 +27,13 @@ stdenvNoCC.mkDerivation (finalAttrs: { writableTmpDirAsHomeHook ]; + postPatch = '' + # NOTE: Relax Bun version check to be a warning instead of an error + substituteInPlace packages/script/src/index.ts \ + --replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \ + 'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}' + ''; + configurePhase = '' runHook preConfigure diff --git a/package.json b/package.json index 61800b8fa74d..0f11d0c3966a 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "prepare": "husky", "random": "echo 'Random script'", "sso": "aws sso login --sso-session=opencode --no-browser", + "translate:app": "bun run script/translate-app.ts", "test": "echo 'do not run tests from root' && exit 1" }, "workspaces": { @@ -30,20 +31,23 @@ "packages/slack" ], "catalog": { - "@effect/opentelemetry": "4.0.0-beta.74", - "@effect/platform-node": "4.0.0-beta.74", - "@effect/sql-sqlite-bun": "4.0.0-beta.74", + "@effect/opentelemetry": "4.0.0-beta.83", + "@effect/platform-node": "4.0.0-beta.83", + "@effect/sql-sqlite-bun": "4.0.0-beta.83", "@npmcli/arborist": "9.4.0", "@types/bun": "1.3.13", "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/standard-validator": "0.2.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.3.4", - "@opentui/keymap": "0.3.4", - "@opentui/solid": "0.3.4", + "@opentui/core": "0.4.5", + "@opentui/keymap": "0.4.5", + "@opentui/solid": "0.4.5", + "@tanstack/solid-virtual": "3.13.32", + "@shikijs/stream": "4.2.0", "ulid": "3.0.1", "@kobalte/core": "0.13.11", + "@corvu/drawer": "0.2.4", "@types/luxon": "3.7.1", "@types/node": "24.12.2", "@types/semver": "7.7.1", @@ -51,22 +55,22 @@ "@tsconfig/bun": "1.0.9", "@cloudflare/workers-types": "4.20251008.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@pierre/diffs": "1.1.0-beta.18", - "opentui-spinner": "0.0.6", + "@pierre/diffs": "1.2.10", + "opentui-spinner": "0.0.7", "@solid-primitives/storage": "4.3.3", "@tailwindcss/vite": "4.1.11", "diff": "8.0.2", "dompurify": "3.3.1", "drizzle-kit": "1.0.0-rc.2", "drizzle-orm": "1.0.0-rc.2", - "effect": "4.0.0-beta.74", + "effect": "4.0.0-beta.83", "ai": "6.0.168", "cross-spawn": "7.0.6", "hono": "4.10.7", "hono-openapi": "1.1.2", "fuzzysort": "3.1.0", "luxon": "3.6.1", - "marked": "17.0.1", + "marked": "18.0.7", "marked-shiki": "1.2.1", "remend": "1.3.0", "@playwright/test": "1.59.1", @@ -76,10 +80,9 @@ "zod": "4.1.8", "remeda": "2.26.0", "sst": "4.13.1", - "shiki": "3.20.0", + "shiki": "4.2.0", "solid-list": "0.3.0", "tailwindcss": "4.1.11", - "virtua": "0.49.1", "vite": "7.1.4", "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", @@ -87,6 +90,7 @@ "@sentry/solid": "10.36.0", "@sentry/vite-plugin": "4.6.0", "solid-js": "1.9.10", + "solid-sonner": "0.3.1", "vite-plugin-solid": "2.11.10", "@lydell/node-pty": "1.2.0-beta.12" } @@ -103,7 +107,7 @@ "prettier": "3.6.2", "semver": "^7.6.0", "sst": "catalog:", - "turbo": "2.8.13" + "turbo": "2.10.2" }, "dependencies": { "@aws-sdk/client-s3": "3.933.0", @@ -140,14 +144,22 @@ "@types/node": "catalog:" }, "patchedDependencies": { + "@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch", + "@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", - "virtua@0.49.1": "patches/virtua@0.49.1.patch", - "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", + "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", + "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", - "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch" + "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", + "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", + "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", + "@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch", + "@ai-sdk/groq@3.0.31": "patches/@ai-sdk%2Fgroq@3.0.31.patch" } } diff --git a/packages/app/AGENTS.md b/packages/app/AGENTS.md index 765e960c8172..72a973ebd40c 100644 --- a/packages/app/AGENTS.md +++ b/packages/app/AGENTS.md @@ -1,3 +1,8 @@ +## Priorities + +- Prioritise, in this order: stability, simplicity, performance. +- Before changing session or timeline code, record a production benchmark baseline and compare it after the change. + ## Debugging - NEVER try to restart the app, or the server process, EVER. @@ -14,6 +19,21 @@ - Always prefer `createStore` over multiple `createSignal` calls +## Localization + +- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors. +- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change. +- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it. +- Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar. +- Prefer complete translated phrases. Do not concatenate grammatical fragments or make call sites assemble sentences. Keep placeholders to irreducible dynamic values such as names, paths, and counts. +- If a translation cannot be expressed by the current API, deepen the shared language/UI i18n module so one typed call owns locale selection, plural resolution, fallback, and interpolation. Do not leak that machinery into product code. +- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`. +- For developer-facing terminology, prefer the words already used by the target language's developer community over literal dictionary translations. Cross-check maintained localized developer products such as Firefox, KDE, and VS Code; use at least two independent corpora when they are available. If established practice keeps an English loanword or acronym, keep it rather than inventing a translation. +- Translate complete UI phrases in context. A glossary hit is evidence, not permission to translate word-by-word. Check terse labels such as session, prompt, agent, model, fork, shell, terminal, workspace, and worktree in the same grammatical role before choosing a term. +- Before a locale is ready, audit recurring concepts for one consistent translation and review every value that still equals English. Classify retained English as a product name, provider/tool name, URL, code token, keyboard legend, acronym, asset name, or established borrowing; translate unexplained leftovers. +- In translation review notes, name the corpora used and call out uncertain or region-specific terminology so native speakers can focus review where it matters. +- Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels. + ## Tool Calling - ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. diff --git a/packages/app/V1_API_MIGRATION.md b/packages/app/V1_API_MIGRATION.md new file mode 100644 index 000000000000..2850f107400c --- /dev/null +++ b/packages/app/V1_API_MIGRATION.md @@ -0,0 +1,220 @@ +# V1 API Migration Checklist + +The app is currently hybrid. In this document, V1 refers to the legacy unprefixed server APIs used by `@opencode-ai/sdk/v2`, despite the SDK package name. + +## Events + +- [x] Replace `GET /global/event` with `GET /api/event`. + - `src/context/server-sdk.tsx` +- [x] Reduce current granular session and message events into the existing app projections. + - `src/context/server-session-v2-reducer.ts` + - `src/context/server-session.ts` +- [ ] Remove transitional session event dependencies: `session.created`, `session.updated`, `session.diff`, `session.status`, `session.idle`, and `session.error`. + - `src/context/global-sync/event-reducer.ts` + - `src/context/server-session.ts` + - `src/context/notification.tsx` + - `src/pages/session/usage-exceeded-dialogs.tsx` +- [ ] Remove legacy message event compatibility: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`, and `message.part.delta`. + - `src/context/global-sync/event-reducer.ts` + - `src/context/server-session.ts` +- [x] Adapt current permission and question events to the existing request model. + - `src/context/global-sync/event-reducer.ts` + - `src/context/permission.tsx` +- [x] Consume current file watcher events. + - `src/context/file.tsx` +- [x] Consume current VCS events. + - `src/context/global-sync/event-reducer.ts` + - `src/pages/session.tsx` +- [x] Consume current `pty.exited` events. + - `src/context/terminal.tsx` +- [ ] Migrate LSP and reference events. + - `src/context/global-sync/event-reducer.ts` + +## Sessions + +- [x] Replace `GET /session/status` with one server-scoped `GET /api/session/active` snapshot plus V2 execution events. + - `src/context/server-sync.tsx` +- [x] Migrate session listing from `GET /session`. + - `src/context/server-sync.tsx` + - `src/context/directory-sync.ts` + - `src/pages/layout.tsx` +- [x] Migrate the remaining direct session read from `GET /session/:sessionID`. + - `src/components/titlebar.tsx` +- [x] Migrate session updates from `PATCH /session/:sessionID`. + - `src/context/directory-sync.ts` + - `src/context/layout.tsx` + - `src/pages/home.tsx` + - `src/pages/layout.tsx` + - `src/pages/session/timeline/message-timeline.tsx` + - `src/components/titlebar-tab-nav.tsx` + - Renames use `POST /api/session/:sessionID/rename`; archival uses `POST /api/session/:sessionID/archive`. +- [x] Migrate session deletion from `DELETE /session/:sessionID`. + - `src/pages/session/timeline/message-timeline.tsx` +- [x] Remove session diff loading from `GET /session/:sessionID/diff`. + - Historical Session diffs remain unavailable until the current API defines their snapshot semantics. +- [x] Migrate abort from `POST /session/:sessionID/abort`. + - `src/components/prompt-input/submit.ts` + - `src/pages/session/use-session-commands.tsx` + - `src/pages/session.tsx` +- [x] Migrate revert and unrevert from `POST /session/:sessionID/revert` and `POST /session/:sessionID/unrevert`. + - `src/pages/session/use-session-commands.tsx` + - `src/pages/session.tsx` +- [x] Replace `POST /session/:sessionID/summarize` with the current compact API. + - `src/pages/session/use-session-commands.tsx` +- [x] Migrate slash commands from `POST /session/:sessionID/command`. + - `src/components/prompt-input/submit.ts` +- [x] Migrate shell execution from `POST /session/:sessionID/shell`. + - `src/components/prompt-input/submit.ts` +- [x] Migrate session fork from `POST /session/:sessionID/fork`. + - `src/components/dialog-fork.tsx` +- [ ] Migrate sharing from `POST /session/:sessionID/share` and `DELETE /session/:sessionID/share`. + - `src/pages/session/use-session-commands.tsx` + - `src/pages/session/timeline/message-timeline.tsx` + - Blocked: the current API has no sharing contract or implementation. + +## Session Compatibility Fallbacks + +These calls are retained as fallback adapters. The current production path supplies the current session and message APIs. + +- [ ] Remove fallback `GET /session/:sessionID` after compatibility support is unnecessary. + - `src/context/server-session.ts` +- [ ] Remove fallback `GET /session/:sessionID/message` after compatibility support is unnecessary. + - `src/context/server-session.ts` +- [ ] Remove fallback `GET /session/:sessionID/message/:messageID` after compatibility support is unnecessary. + - `src/context/server-session.ts` + +## Filesystem + +- [ ] Migrate file listing from `GET /file`. + - `src/context/file.tsx` +- [ ] Migrate file reads from `GET /file/content`. + - `src/context/file.tsx` + - `src/pages/session/review-tab.tsx` + - `src/pages/session/v2/review-panel-v2.tsx` +- [x] Migrate path discovery from `GET /path` to `GET /api/path`. + - `src/context/global-sync/bootstrap.ts` + - `src/components/dialog-select-directory.tsx` + - `src/components/dialog-select-directory-v2.tsx` + +## Projects And Worktrees + +- [x] Migrate project listing from `GET /project` to `GET /api/project`. + - `src/context/global-sync/bootstrap.ts` +- [x] Migrate the current project lookup from `GET /project/current` to `GET /api/project/current`. + - `src/context/global-sync/bootstrap.ts` +- [ ] Migrate Git initialization from `POST /project/git/init`. + - `src/pages/session.tsx` +- [x] Migrate project updates from `PATCH /project/:projectID` to `PATCH /api/project/:projectID`. + - `src/context/layout.tsx` + - `src/components/edit-project.ts` + - `src/pages/layout.tsx` +- [ ] Migrate experimental worktree listing, creation, removal, and reset from `/experimental/worktree`. + - `src/pages/layout.tsx` + - `src/components/prompt-input/submit.ts` + - Listing now uses `GET /api/project/:projectID/directories`; create, removal, and reset remain. +- [ ] Migrate instance disposal from `POST /instance/dispose`. + - `src/pages/layout.tsx` + +## VCS + +- [x] Migrate repository information from `GET /vcs` to `GET /api/vcs`. + - `src/context/global-sync/bootstrap.ts` +- [x] Migrate diffs from `GET /vcs/diff` to `GET /api/vcs/diff`. + - `src/pages/session.tsx` +- [x] Migrate status from `GET /vcs/status` to `GET /api/vcs/status`. + - `src/pages/layout.tsx` + +## Configuration And Authentication + +- [ ] Migrate global configuration reads from `GET /global/config`. + - `src/context/global-sync/bootstrap.ts` +- [ ] Migrate directory configuration reads from `GET /config`. + - `src/context/global-sync/bootstrap.ts` +- [ ] Migrate global configuration updates from `PATCH /global/config`. + - `src/context/server-sync.tsx` +- [x] Migrate provider authentication method discovery from `GET /provider/auth` to `GET /api/integration/:integrationID`. + - `src/components/dialog-connect-provider.tsx` +- [x] Migrate built-in provider OAuth authorization and callbacks to `/api/integration/:integrationID/connect/oauth/*`. + - `src/components/dialog-connect-provider.tsx` +- [ ] Migrate remaining credentials from `PUT /auth/:providerID` and `DELETE /auth/:providerID`. + - Built-in provider key connections now use `POST /api/integration/:integrationID/connect/key`. + - `src/components/dialog-connect-provider.tsx` + - `src/components/dialog-custom-provider.tsx` + - `src/components/settings-providers.tsx` + - `src/components/settings-v2/providers.tsx` +- [ ] Migrate global disposal from `POST /global/dispose`. + - `src/components/dialog-connect-provider.tsx` + - `src/components/settings-providers.tsx` + - `src/components/settings-v2/providers.tsx` + +## Permissions And Questions + +- [x] Migrate permission listing from `GET /permission` to `GET /api/permission/request`. + - `src/context/global-sync/bootstrap.ts` + - `src/context/permission.tsx` +- [x] Migrate permission responses from `/session/:sessionID/permissions/:permissionID`. + - `src/context/permission.tsx` + - `src/pages/session/composer/session-composer-state.ts` +- [x] Migrate question listing from `GET /question` to `GET /api/question/request`. + - `src/context/global-sync/bootstrap.ts` +- [x] Migrate question replies and rejections from `/question/:requestID/*` to `/api/session/:sessionID/question/:requestID/*`. + - `src/pages/session/composer/session-question-dock.tsx` + +## Commands, MCP, LSP, And References + +- [x] Migrate command listing from `GET /command` to `GET /api/command`. + - `src/context/global-sync/bootstrap.ts` + - `src/context/server-sync.tsx` +- [x] Migrate MCP listing, connection, and disconnection from `/mcp` to `/api/mcp`. + - `src/context/server-sync.tsx` +- [ ] Replace legacy MCP authentication with the Integration OAuth workflow. + - `src/context/server-sync.tsx` +- [x] Migrate experimental resource listing from `GET /experimental/resource` to `GET /api/mcp/resource`. + - `src/context/server-sync.tsx` +- [ ] Migrate LSP status from `GET /lsp`. + - `src/context/server-sync.tsx` +- [x] Move `GET /api/reference` off the legacy generated SDK transport. + - `src/context/global-sync/bootstrap.ts` + +## Search + +- [x] Migrate global session search from `GET /experimental/session` to `GET /api/session`. + - `src/components/command-palette.ts` + - `src/components/dialog-command-palette-v2.tsx` + +## PTY And Terminal + +- [x] Migrate PTY creation, reads, updates, and deletion from `/pty` to `/api/pty`. + - `src/context/terminal.tsx` + - `src/components/terminal.tsx` +- [x] Migrate shell listing from `GET /pty/shells` to `GET /api/pty/shells`. + - `src/components/settings-general.tsx` + - `src/components/settings-v2/general.tsx` +- [x] Migrate connection tokens from `POST /pty/:ptyID/connect-token` to `POST /api/pty/:ptyID/connect-token`. + - `src/components/terminal.tsx` +- [x] Migrate the direct WebSocket connection from `/pty/:ptyID/connect` to `/api/pty/:ptyID/connect`. + - `src/components/terminal.tsx` + +## Legacy Types And Adapters + +These are not V1 network requests, but they keep the UI coupled to V1 data contracts. + +- [ ] Replace the current-session-to-legacy-session adapter. + - `src/utils/session.ts` +- [ ] Replace the current-message-to-legacy-message-and-part adapter. + - `src/utils/session-message.ts` +- [ ] Replace current agent, provider, and model adapters to legacy SDK structures. + - `src/context/global-sync/utils.ts` +- [ ] Replace legacy `Session`, `Message`, `Part`, `PermissionRequest`, `QuestionRequest`, `Project`, `FileNode`, `FileDiffInfo`, and `Event` types throughout app state and rendering. +- [ ] Remove the `@opencode-ai/sdk` runtime dependency after all legacy calls and types are gone. + - `package.json` + +## Test Infrastructure + +- [ ] Replace V1 endpoint mocks with current API mocks. + - `e2e/utils/mock-server.ts` +- [x] Replace `/global/event` and `/event` interception with current event transport handling. + - `e2e/utils/sse-transport.ts` +- [ ] Replace `SessionV1` and legacy SDK fixtures in timeline performance tests. + - `e2e/performance/timeline-stability/fixture.ts` +- [ ] Remove remaining legacy SDK type fixtures from unit and browser tests. diff --git a/packages/app/e2e/AGENTS.md b/packages/app/e2e/AGENTS.md new file mode 100644 index 000000000000..e0c6c006021d --- /dev/null +++ b/packages/app/e2e/AGENTS.md @@ -0,0 +1,16 @@ +## Required Reading + +- Before writing, changing, or reviewing E2E tests, ALWAYS read and follow Playwright's official [Best Practices](https://playwright.dev/docs/best-practices), [Auto-waiting](https://playwright.dev/docs/actionability), and [Assertions](https://playwright.dev/docs/test-assertions) guides. +- Use the official [Locators](https://playwright.dev/docs/locators), [Network](https://playwright.dev/docs/network), and [Test Isolation](https://playwright.dev/docs/browser-contexts) guides when those concerns apply. + +## Test Hygiene + +- Test user-visible behavior with isolated, deterministic data and scoped, unique locators. +- Prefer role, label, text, and explicit test-contract locators. Do not use `.first()` or `.last()` merely to silence strictness errors. +- Use locator actions, Playwright auto-waiting, and web-first assertions for observable readiness and outcomes. +- NEVER use `waitForTimeout`, `setTimeout`, sleeps, animation-frame counts, or other wall-clock delays to synchronize a test. Wait for the specific UI state, request, response, event, or application outcome instead. +- Do not treat navigation, a network response, DOM attachment, or visibility alone as proof that asynchronously rendered UI is ready. Assert the state the next action actually requires. +- Register event and network waits before the action that triggers them. +- Do not retry state-changing actions. Retry idempotent readiness checks, then perform the action once and assert its outcome. +- Keep action and assertion timeouts adaptive. Do not use short timeouts as readiness probes or rely on retries to hide flakes. +- Assert exact outcomes and identities so stale state, duplicate rendering, and interactions with the wrong element cannot pass. diff --git a/packages/app/e2e/performance/AGENTS.md b/packages/app/e2e/performance/AGENTS.md new file mode 100644 index 000000000000..e69c91a98fc1 --- /dev/null +++ b/packages/app/e2e/performance/AGENTS.md @@ -0,0 +1,13 @@ +- Prioritize stability, then simplicity, then measurement overhead. +- Use Playwright for scenario control, isolation, and completion checks. +- Use Chrome Performance traces for generic browser profiling. +- Use Electron `contentTracing` for packaged multi-process profiling. +- Keep custom probes only for product-specific measurements. +- Do not duplicate measurements across the harness, probes, and traces. +- Run benchmarks serially to avoid cross-test contention. +- Run benchmarks against production builds. +- Keep detailed profiling opt-in when it changes workload behavior. +- Preserve raw diagnostic data or use lossless representations. +- Do not enforce machine-dependent performance thresholds. +- Assert scenario completion and metric collection only. +- Keep normal test discovery free of manual benchmarks. diff --git a/packages/app/e2e/performance/README.md b/packages/app/e2e/performance/README.md new file mode 100644 index 000000000000..ce868d573bb7 --- /dev/null +++ b/packages/app/e2e/performance/README.md @@ -0,0 +1,79 @@ +# Manual app performance suite + +The app's high-volume performance diagnostics live under `packages/app/e2e/performance` and are excluded from normal local and CI Playwright discovery. The benchmark config builds the app and serves the production bundle before running scenarios serially. + +Run the suite explicitly from `packages/app`: + +```sh +bun run test:bench +``` + +PowerShell: + +```powershell +$env:PLAYWRIGHT_WORKERS = "1" +bun run test:bench +``` + +The suite contains: + +- cold and hot session-tab timing +- home-session click timing split between content and titlebar-tab paint +- single-session tab close timing through stable home restoration +- cached session repaint and mutation tracing +- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics + +All benchmarks import the shared `benchmark` fixture. Pages created through Playwright's `page` fixture automatically capture main-frame navigation history and emit a Chrome trace when `OPENCODE_PERFORMANCE_TRACE_DIR` is set. Benchmarks that need isolated browser contexts use `withBenchmarkPage`, which owns the context and the same diagnostics lifecycle. + +New benchmarks should look like normal Playwright tests: + +```ts +import { benchmark, expect } from "../benchmark" + +benchmark("measures one interaction", async ({ page, report }) => { + // Only scenario-specific setup and interaction belong here. + report({ durationMs: 42 }) +}) +``` + +The fixture requires every benchmark to call `report()`, automatically names and closes traces, captures navigation history, attaches that history when a test fails, and emits metrics as a consistent `BENCHMARK` JSON line. + +```text +BENCHMARK {"name":"...","context":{"project":"chromium","platform":"darwin"},"metrics":{...}} +``` + +Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, and optional trace path before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide page-lifetime diagnostics; scenario metrics use narrower explicitly named observation windows. + +This follows the stack's own guidance: [Electron recommends repeated Chrome DevTools and Chrome Tracing measurement](https://www.electronjs.org/docs/latest/tutorial/performance), [Chrome DevTools recommends Performance recordings for runtime work](https://developer.chrome.com/docs/devtools/performance), and [Playwright uses traces for test debugging rather than renderer profiling](https://playwright.dev/docs/trace-viewer). + +These Playwright benchmarks profile the shared app renderer in Chromium. A future packaged Electron benchmark that needs main-process and multi-process attribution should use Electron's official [`contentTracing`](https://www.electronjs.org/docs/latest/api/content-tracing/) API rather than extending this renderer harness with bespoke process instrumentation. + +CPU and high-volume visual profiling are disabled by default. Set `TIMELINE_CPU_PROFILE=1` to enable both, or additionally set `TIMELINE_VISUAL_PROFILE=0` for CPU-only profiling. + +The streaming scenario's 30x CPU throttle is a deterministic stress profile, not a simulated end-user device. + +Benchmarks do not assert machine-dependent performance budgets. Streaming processes 160 deltas by default and reports renderer-observed completion time, throughput, RAF callback-gap distributions, frame-budget equivalents, and long tasks through final geometry settlement. Delta count and delivery batch are included in result context when overridden. These are main-thread callback diagnostics, not compositor presentation or dropped-frame measurements. Visual-only and geometry metrics are `null` when their probes are disabled. Tab metrics describe sampled DOM observations. Assertions verify scenario and metric collection completion. Repeated repaint states are run-length grouped, but every original observation timestamp is retained alongside raw mutation batches and layout shifts. + +Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing. + +## Chrome traces + +Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically: + +```sh +OPENCODE_PERFORMANCE_TRACE_DIR=/tmp/opencode-performance-traces \ +bunx playwright test --config e2e/performance/playwright.config.ts \ + timeline/session-tab-switch-benchmark.spec.ts +``` + +The emitted JSON is a standard Chrome trace and can be loaded directly into the Chrome DevTools Performance panel. `devtools-tracing` can optionally inspect it from the command line without adding package scripts or dependencies: + +Trace capture mirrors [Puppeteer's official tracing defaults and lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss. + +```sh +bunx devtools-tracing stats +``` + +INP analysis requires a trace with a supported navigation/interaction insight. Selector statistics require a trace captured with `OPENCODE_PERFORMANCE_SELECTOR_TRACE=1`. + +`e2e/performance/playwright.uncapped.config.ts` disables Chromium frame-rate limiting for explicit uncapped diagnostics. Native product benchmarks should use the default Playwright configuration. diff --git a/packages/app/e2e/performance/benchmark.ts b/packages/app/e2e/performance/benchmark.ts new file mode 100644 index 000000000000..b9f8ea434117 --- /dev/null +++ b/packages/app/e2e/performance/benchmark.ts @@ -0,0 +1,144 @@ +import { expect, test as base, type Browser, type Page, type TestInfo } from "@playwright/test" +import { startChromeTrace } from "./chrome-trace" + +type BenchmarkFixtures = { + report: (metrics: Record, context?: Record) => void + reportState: { payload?: { metrics: Record; context: Record } } + benchmarkResult: void +} + +export type PerformancePageDiagnostics = { + navigations: string[] + stop: () => Promise +} + +const pages = new WeakMap() + +export const benchmark = base.extend({ + reportState: async ({}, use) => use({}), + report: async ({ reportState }, use) => { + await use((metrics, context = {}) => { + if (reportState.payload) throw new Error("Benchmark reported metrics more than once") + reportState.payload = { metrics, context } + }) + }, + benchmarkResult: [ + async ({ reportState }, use, testInfo) => { + await use() + const missing = !reportState.payload + console.log( + `BENCHMARK ${JSON.stringify({ + schemaVersion: 2, + runID: process.env.OPENCODE_PERFORMANCE_RUN_ID, + name: benchmarkName(testInfo), + status: missing ? "failed" : testInfo.status, + expectedStatus: testInfo.expectedStatus, + retry: testInfo.retry, + repeatEachIndex: testInfo.repeatEachIndex, + context: { + project: testInfo.project.name, + platform: process.platform, + ...reportState.payload?.context, + }, + metrics: reportState.payload?.metrics ?? null, + error: missing ? "Benchmark did not report metrics" : undefined, + })}`, + ) + if (missing && testInfo.status === testInfo.expectedStatus) + throw new Error(`Benchmark did not report metrics: ${benchmarkName(testInfo)}`) + }, + { auto: true }, + ], + page: async ({ page }, use, testInfo) => { + const name = benchmarkName(testInfo) + const diagnostics = await observePerformancePage(page, name) + try { + await use(page) + } finally { + try { + await reportPerformancePage(name, diagnostics, testInfo) + } finally { + if (testInfo.status !== testInfo.expectedStatus) { + await testInfo.attach("performance-navigations", { + body: JSON.stringify(diagnostics.navigations, null, 2), + contentType: "application/json", + }) + } + } + } + }, +}) + +function benchmarkName(testInfo: TestInfo) { + return testInfo.titlePath.slice(1).join(" > ") +} + +export { expect } + +async function observePerformancePage(page: Page, name: string) { + const navigations: string[] = [] + const onNavigation = (frame: ReturnType) => { + if (frame === page.mainFrame()) navigations.push(frame.url()) + } + page.on("framenavigated", onNavigation) + const stopTrace = await startChromeTrace(page, name).catch((error) => { + page.off("framenavigated", onNavigation) + throw error + }) + let stopping: Promise | undefined + const diagnostics: PerformancePageDiagnostics = { + navigations, + stop() { + page.off("framenavigated", onNavigation) + return (stopping ??= stopTrace?.() ?? Promise.resolve(undefined)) + }, + } + pages.set(page, diagnostics) + return diagnostics +} + +export async function withBenchmarkPage( + browser: Browser, + name: string, + run: (page: Page) => Promise, + testInfo?: TestInfo, +) { + const context = await browser.newContext() + try { + const page = await context.newPage() + const diagnostics = await observePerformancePage(page, name) + try { + return await run(page) + } finally { + await reportPerformancePage(name, diagnostics, testInfo) + } + } finally { + await context.close() + } +} + +async function reportPerformancePage(name: string, diagnostics: PerformancePageDiagnostics, testInfo?: TestInfo) { + const trace = await diagnostics.stop() + console.log( + `BENCHMARK_PAGE ${JSON.stringify({ + schemaVersion: 2, + runID: process.env.OPENCODE_PERFORMANCE_RUN_ID, + name, + test: testInfo ? benchmarkName(testInfo) : undefined, + retry: testInfo?.retry, + repeatEachIndex: testInfo?.repeatEachIndex, + context: { + platform: process.platform, + trace, + selectorTrace: process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1", + }, + navigations: diagnostics.navigations, + })}`, + ) +} + +export function benchmarkDiagnostics(page: Page) { + const diagnostics = pages.get(page) + if (!diagnostics) throw new Error("Performance diagnostics are not installed for this page") + return diagnostics +} diff --git a/packages/app/e2e/performance/chrome-trace.ts b/packages/app/e2e/performance/chrome-trace.ts new file mode 100644 index 000000000000..343526e254df --- /dev/null +++ b/packages/app/e2e/performance/chrome-trace.ts @@ -0,0 +1,95 @@ +import type { CDPSession, Page } from "@playwright/test" +import path from "node:path" +import { mkdir, open, rename } from "node:fs/promises" +import { Buffer } from "node:buffer" +import { createHash, randomUUID } from "node:crypto" + +const categories = [ + "-*", + "devtools.timeline", + "v8.execute", + "disabled-by-default-devtools.timeline", + "disabled-by-default-devtools.timeline.frame", + "toplevel", + "blink.console", + "blink.user_timing", + "latencyInfo", + "disabled-by-default-devtools.timeline.stack", + "disabled-by-default-v8.cpu_profiler", +] + +export async function startChromeTrace(page: Page, name: string) { + const directory = process.env.OPENCODE_PERFORMANCE_TRACE_DIR + if (!directory) return + + const selectors = process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1" + const file = await prepareChromeTrace(directory, name, selectors) + const session = await page.context().newCDPSession(page) + try { + await session.send("Tracing.start", { + transferMode: "ReturnAsStream", + traceConfig: { + excludedCategories: categories + .filter((category) => category.startsWith("-")) + .map((category) => category.slice(1)), + includedCategories: [ + ...categories.filter((category) => !category.startsWith("-")), + ...(selectors + ? ["disabled-by-default-blink.debug", "disabled-by-default-devtools.timeline.invalidationTracking"] + : []), + ], + }, + }) + } catch (error) { + await Promise.allSettled([session.detach()]) + throw error + } + let stopping: Promise | undefined + + return () => + (stopping ??= (async () => { + try { + const complete = new Promise<{ stream?: string; dataLossOccurred: boolean }>((resolve) => + session.once("Tracing.tracingComplete", resolve), + ) + await session.send("Tracing.end") + const result = await complete + if (!result.stream) throw new Error(`Chrome trace stream missing: ${file}`) + const partial = `${file}.partial` + await writeProtocolStream(session, result.stream, partial) + if (result.dataLossOccurred) throw new Error(`Chrome trace lost data; partial capture retained: ${partial}`) + await rename(partial, file) + return file + } finally { + await Promise.allSettled([session.detach()]) + } + })()) +} + +export async function prepareChromeTrace( + directory: string, + name: string, + selectors: boolean, + nonce = randomUUID().slice(0, 8), +) { + await mkdir(directory, { recursive: true }) + const run = process.env.OPENCODE_PERFORMANCE_RUN_ID ?? "manual" + const hash = createHash("sha256").update(name).digest("hex").slice(0, 8) + return path.join( + directory, + `${run}-${name.replace(/[^a-zA-Z0-9_-]/g, "-")}-${hash}-${nonce}${selectors ? "-selectors" : ""}.json`, + ) +} + +async function writeProtocolStream(session: CDPSession, handle: string, file: string) { + const output = await open(file, "wx") + try { + while (true) { + const chunk = await session.send("IO.read", { handle }) + await output.write(chunk.base64Encoded ? Buffer.from(chunk.data, "base64") : chunk.data) + if (chunk.eof) break + } + } finally { + await Promise.allSettled([output.close(), session.send("IO.close", { handle })]) + } +} diff --git a/packages/app/e2e/performance/playwright.config.ts b/packages/app/e2e/performance/playwright.config.ts new file mode 100644 index 000000000000..d4793daee58d --- /dev/null +++ b/packages/app/e2e/performance/playwright.config.ts @@ -0,0 +1,20 @@ +import config from "../../playwright.config" + +const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000) +process.env.PLAYWRIGHT_SERVER_PORT = String(port) +process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}` + +export default { + ...config, + testDir: ".", + testIgnore: "unit/**", + outputDir: "../test-results/performance", + fullyParallel: false, + workers: 1, + reporter: [["html", { outputFolder: "../playwright-report/performance", open: "never" }], ["line"]], + webServer: { + ...config.webServer, + command: `bun run build && bun run serve -- --host 0.0.0.0 --port ${port} --strictPort`, + reuseExistingServer: false, + }, +} diff --git a/packages/app/e2e/performance/playwright.uncapped.config.ts b/packages/app/e2e/performance/playwright.uncapped.config.ts new file mode 100644 index 000000000000..9097c11f1d07 --- /dev/null +++ b/packages/app/e2e/performance/playwright.uncapped.config.ts @@ -0,0 +1,13 @@ +import config from "./playwright.config" + +export default { + ...config, + outputDir: "../test-results/performance-uncapped", + reporter: [["html", { outputFolder: "../playwright-report/performance-uncapped", open: "never" }], ["line"]], + use: { + ...config.use, + launchOptions: { + args: ["--disable-frame-rate-limit", "--disable-gpu-vsync"], + }, + }, +} diff --git a/packages/app/e2e/performance/timeline-stability/README.md b/packages/app/e2e/performance/timeline-stability/README.md new file mode 100644 index 000000000000..3e9def275730 --- /dev/null +++ b/packages/app/e2e/performance/timeline-stability/README.md @@ -0,0 +1,61 @@ +# Timeline Layout Continuity + +Run from `packages/app`: + +```sh +bun run test:stability +``` + +The suite runs a production build in one Chromium worker. Selected scenarios use deterministic 4x CPU stress after application readiness. This is a stress profile, not emulation of a specific device. + +## What It Proves + +The continuity probe samples DOM-derived layout and visibility state across browser render opportunities. Tests declare explicit contracts such as: + +- Preserve a visible semantic anchor while the user is away from the bottom. +- Preserve end anchoring while active content grows or new content appears. +- Keep adjacent visible rows ordered without material overlap. +- Keep user-selected disclosure state through updates and virtualization. +- Avoid a sampled blank interval while one visible surface replaces another. +- Preserve logical row and control identity where local state or focus depends on it. +- Keep keyboard, wheel, and nested-scroll ownership consistent during remeasurement. + +The suite exercises real browser reducer, projection, component, virtualizer, layout, focus, and interaction code. The backend and event producer are controlled fixtures. + +## What It Does Not Prove + +The pass/fail oracle does not inspect every compositor-presented pixel. A sample taken after `requestAnimationFrame` is a DOM/layout observation, not proof that every sampled state was displayed or that every displayed frame was sampled. + +The suite does not provide complete coverage for: + +- Compositor-only or raster-only glitches. +- Color, contrast, canvas, WebGL, masks, irregular clips, or arbitrary occlusion. +- Physical display refresh rates, native OS scaling, or a named low-end device. +- TCP packetization, proxy buffering, or the complete real server/provider pipeline. + +Playwright video, trace, screenshots, and observation JSON are diagnostic evidence. They are not pixel baselines and do not participate in normal pass/fail decisions. + +For optional before/violation/after screenshots, set `OPENCODE_STABILITY_CAPTURE=1`. Capture is opt-in because compositor readback can perturb timing. + +## Test Layers + +- **Projection:** admitted rows, grouping, labels, and final visible states. +- **Local state:** disclosure state, identity, duplicate delivery, and virtualization restoration. +- **Interaction:** wheel, keyboard, nested scrolling, actionability, and focus behavior. +- **Layout continuity:** anchoring, adjacency, responsive reflow, and visible surface handoffs. +- **Reducer hardening:** validly shaped but intentionally reordered, duplicated, removed, or replaced events. +- **Oracle contract:** pure analyzer and browser sampler calibration tests. + +Production-lifecycle fixtures should model states emitted by the current producer. Impossible or reordered sequences belong in reducer-hardening tests and must not be described as normal provider behavior. + +## Diagnostics + +Failures retain: + +- `video.webm` +- `trace.zip` +- failure screenshot +- sampled DOM/layout trace JSON +- event markers and summarized violations + +The analyzer records both unclipped layout bounds and ancestor-clipped visible intersections. Scrollbar and raw `scrollTop` changes alone do not fail continuity checks; user-visible semantic anchor movement does. diff --git a/packages/app/e2e/performance/timeline-stability/adverse.spec.ts b/packages/app/e2e/performance/timeline-stability/adverse.spec.ts new file mode 100644 index 000000000000..7ce1be906459 --- /dev/null +++ b/packages/app/e2e/performance/timeline-stability/adverse.spec.ts @@ -0,0 +1,250 @@ +import { expect, test } from "@playwright/test" +import { + defineVisualRegions, + reportVisualStability, + startVisualProbe, + stopVisualProbe, + visualPlan, +} from "../../utils/visual-stability" +import { + assistantMessage, + partUpdated, + setupTimeline, + shell, + textPart, + toolPart, + userMessage, + waitForVisualSettle, + type TimelineMessage, +} from "./fixture" + +test.describe("timeline adverse visual stability", () => { + test("does not pull a scrolled-away user while an active shell grows", async ({ page }, testInfo) => { + const activeShellID = "prt_adverse_01_shell" + const messages = [ + ...history(24), + userMessage(), + assistantMessage([shell(activeShellID, "running")], { completed: false }), + ] + const timeline = await setupTimeline(page, { + messages, + settings: { shellToolPartsExpanded: true }, + cpuRate: 4, + eventRetry: 30, + }) + const scroller = page.locator(".scroll-view__viewport", { + has: page.locator('[data-timeline-row="AssistantPart"]'), + }) + await scroller.evaluate((element) => { + element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -450 })) + element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 450) + }) + await page.waitForTimeout(150) + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeGreaterThan(100) + const anchor = await scroller.evaluate((element) => { + const view = element.getBoundingClientRect() + return [...element.querySelectorAll("[data-timeline-key]")].find((row) => { + const rect = row.getBoundingClientRect() + return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40 + })?.dataset.timelineKey + }) + expect(anchor).toBeTruthy() + await waitForVisualSettle(page, [`[data-timeline-key="${anchor}"]`]) + + const regions = defineVisualRegions({ + anchor: { selector: `[data-timeline-key="${anchor}"]` }, + }) + await startVisualProbe(page, regions) + await timeline.send(partUpdated(shell(activeShellID, "running", lines(1))), 180) + await timeline.send(partUpdated(shell(activeShellID, "running", lines(10))), 90) + await timeline.send(partUpdated(shell(activeShellID, "running", lines(50))), 350) + await timeline.send(partUpdated(shell(activeShellID, "completed", lines(50))), 500) + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + "scrolled-away-shell", + trace, + visualPlan(regions, [ + { type: "required", regions: ["anchor"] }, + { type: "unique", regions: ["anchor"] }, + { type: "stable", regions: ["anchor"] }, + { type: "fixed", regions: ["anchor"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxPositionReversals: 0 }, + { type: "label-stability", regions: "all" }, + ]), + ) + }) + + test("preserves an explicit shell state across virtualization", async ({ page }) => { + const targetID = "prt_virtual_shell" + const messages = [ + userMessage(undefined, { id: "msg_0000_virtual_user", created: 1700000000000 }), + assistantMessage([shell(targetID, "completed", lines(20))], { + id: "msg_0001_virtual_assistant", + parentID: "msg_0000_virtual_user", + created: 1700000001000, + }), + ...history(35, 10), + ] + await setupTimeline(page, { messages, settings: { shellToolPartsExpanded: false } }) + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + await scroller.evaluate((element) => { + element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1_000 })) + element.scrollTop = 0 + }) + await page.waitForTimeout(300) + const trigger = page.locator(`[data-timeline-part-id="${targetID}"] [data-slot="collapsible-trigger"]`) + await expect(trigger).toBeVisible() + await trigger.click() + await expect(trigger).toHaveAttribute("aria-expanded", "true") + + await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight)) + await expect(page.locator(`[data-timeline-part-id="${targetID}"]`)).toHaveCount(0) + await scroller.evaluate((element) => (element.scrollTop = 0)) + await expect(trigger).toBeVisible() + await expect(trigger).toHaveAttribute("aria-expanded", "true") + }) + + test("keeps narrow viewport rows ordered during long shell growth", async ({ page }, testInfo) => { + const shellID = "prt_narrow_01_shell" + const followingID = "prt_narrow_02_following" + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage( + [shell(shellID, "running"), textPart(followingID, "A narrow following row that wraps across lines.")], + { + completed: false, + }, + ), + ], + settings: { shellToolPartsExpanded: true }, + viewport: { width: 430, height: 800 }, + cpuRate: 4, + }) + await waitForVisualSettle(page, [ + `[data-timeline-part-id="${shellID}"]`, + `[data-timeline-part-id="${followingID}"]`, + ]) + const regions = defineVisualRegions({ + shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + following: { + selector: `[data-timeline-part-id="${followingID}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, + }) + await startVisualProbe(page, regions) + await timeline.send(partUpdated(shell(shellID, "running", wideLines(10))), 100) + await timeline.send(partUpdated(shell(shellID, "running", wideLines(50))), 300) + await timeline.send(partUpdated(shell(shellID, "completed", wideLines(50))), 500) + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + "narrow-shell", + trace, + visualPlan( + regions, + [ + { type: "required", regions: ["shell", "following"] }, + { type: "unique", regions: ["shell", "following"] }, + { type: "stable", regions: ["shell", "following"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxPositionReversals: 0 }, + { type: "label-stability", regions: "all" }, + { type: "preserve-bottom-anchor" }, + { type: "flow", regions: ["shell", "following"] }, + ], + { perMarker: true }, + ), + ) + }) + + test("keeps visible rows ordered while resizing desktop to narrow and back", async ({ page }, testInfo) => { + const shellID = "prt_resize_01_shell" + const contextIDs = ["prt_resize_02_read", "prt_resize_03_glob"] + const followingID = "prt_resize_04_following" + await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + shell(shellID, "completed", wideLines(15)), + toolPart(contextIDs[0]!, "read", "completed", { filePath: "src/a.ts" }), + toolPart(contextIDs[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }), + textPart(followingID, "Following responsive timeline content that wraps on narrow screens."), + ]), + ], + settings: { shellToolPartsExpanded: true }, + cpuRate: 4, + seedHistory: true, + }) + const group = `[data-timeline-part-ids="${contextIDs.join(",")}"]` + const regions = defineVisualRegions({ + shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' }, + following: { + selector: `[data-timeline-part-id="${followingID}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, + }) + await startVisualProbe(page, regions) + await page.setViewportSize({ width: 430, height: 800 }) + await page.waitForTimeout(500) + await page.setViewportSize({ width: 900, height: 800 }) + await page.waitForTimeout(500) + await page.setViewportSize({ width: 1400, height: 900 }) + await page.waitForTimeout(500) + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + "responsive-resize", + trace, + visualPlan(regions, [ + { type: "required", regions: ["shell", "context", "following"] }, + { type: "unique", regions: ["shell", "context", "following"] }, + { type: "stable", regions: ["shell", "context", "following"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxPositionReversals: 4, maxReversals: 4 }, + { type: "label-stability", regions: "all" }, + { type: "flow", regions: ["shell", "context", "following"] }, + ]), + ) + }) +}) + +function history(count: number, offset = 0): TimelineMessage[] { + return Array.from({ length: count }, (_, index) => { + const value = index + offset + const prefix = `msg_0${String(value).padStart(3, "0")}_history` + const userID = `${prefix}_a_user` + return [ + userMessage(undefined, { id: userID, created: 1699990000000 + value * 10_000 }), + assistantMessage( + [ + textPart( + `prt_history_${String(value).padStart(3, "0")}`, + `Historical response ${value}. ${"Stable history content. ".repeat(8)}`, + ), + ], + { + id: `${prefix}_b_assistant`, + parentID: userID, + created: 1699990001000 + value * 10_000, + }, + ), + ] + }).flat() +} + +function lines(count: number) { + return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n") +} + +function wideLines(count: number) { + return Array.from({ length: count }, (_, index) => `line ${index + 1} ${"wide-output-".repeat(20)}`).join("\n") +} diff --git a/packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts new file mode 100644 index 000000000000..b3438b77c06b --- /dev/null +++ b/packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts @@ -0,0 +1,192 @@ +import { expect, test } from "@playwright/test" +import { + defineVisualRegions, + reportVisualStability, + startVisualProbe, + stopVisualProbe, + visualPlan, +} from "../../utils/visual-stability" +import { + assistantID, + assistantMessage, + event, + partUpdated, + setupTimeline, + textPart, + toolPart, + userMessage, + waitForVisualSettle, +} from "./fixture" + +const inputs = { + read: { filePath: "src/a.ts", offset: 0, limit: 120 }, + glob: { path: ".", pattern: "**/*.ts" }, + grep: { path: ".", pattern: "stable", include: "*.ts" }, + list: { path: "src" }, +} + +test("appends context operations while the group is expanded", async ({ page }, testInfo) => { + const firstID = "prt_append_01_read" + const followingID = "prt_append_99_following" + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([toolPart(firstID, "read", "running", inputs.read), textPart(followingID, "Following append")], { + completed: false, + }), + ], + cpuRate: 4, + }) + const initialGroup = `[data-timeline-part-ids="${firstID}"]` + await page.locator(`${initialGroup} [data-slot="collapsible-trigger"]`).click() + await waitForVisualSettle(page, [initialGroup, `[data-timeline-part-id="${followingID}"]`]) + const regions = defineVisualRegions({ + context: { + selector: '[data-timeline-part-ids^="prt_append_01_read"]', + closest: '[data-timeline-row="AssistantPart"]', + }, + following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + }) + await startVisualProbe(page, regions) + await timeline.send(partUpdated(toolPart("prt_append_02_glob", "glob", "running", inputs.glob)), 180) + await timeline.send(partUpdated(toolPart("prt_append_03_grep", "grep", "completed", inputs.grep)), 240) + await timeline.send(partUpdated(toolPart("prt_append_04_list", "list", "completed", inputs.list)), 500) + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + "context-append", + trace, + visualPlan( + regions, + [ + { type: "required", regions: ["context", "following"] }, + { type: "unique", regions: ["context", "following"] }, + { type: "stable", regions: ["context", "following"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: ["following"], maxPositionReversals: 0 }, + { type: "label-stability", regions: "all" }, + { type: "preserve-bottom-anchor" }, + { type: "flow", regions: ["context", "following"] }, + ], + { perMarker: true }, + ), + ) + await expect( + page.locator( + '[data-timeline-part-ids="prt_append_01_read,prt_append_02_glob,prt_append_03_grep,prt_append_04_list"]', + ), + ).toBeVisible() + await expect( + page.locator('[data-timeline-part-ids^="prt_append_01_read"] [data-slot="collapsible-trigger"]'), + ).toHaveAttribute("aria-expanded", "true") +}) + +test("splits and merges context groups when a middle text part changes", async ({ page }, testInfo) => { + const textID = "prt_split_02_text" + const followingID = "prt_split_99_following" + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + toolPart("prt_split_01_read", "read", "completed", inputs.read), + textPart(textID, "Boundary"), + toolPart("prt_split_03_glob", "glob", "completed", inputs.glob), + textPart(followingID, "Following split groups"), + ]), + ], + cpuRate: 4, + }) + const regions = defineVisualRegions({ + following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + }) + await startVisualProbe(page, regions) + await timeline.send( + event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: textID }), + 500, + ) + await expect(page.locator('[data-timeline-part-ids="prt_split_01_read,prt_split_03_glob"]')).toBeVisible() + await timeline.send(partUpdated(textPart(textID, "Boundary restored")), 500) + await expect(page.locator('[data-timeline-part-ids="prt_split_01_read"]')).toBeVisible() + await expect(page.locator('[data-timeline-part-ids="prt_split_03_glob"]')).toBeVisible() + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + "context-split-merge", + trace, + visualPlan( + regions, + [ + { type: "required", regions: ["following"] }, + { type: "unique", regions: ["following"] }, + { type: "stable", regions: ["following"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxPositionReversals: 1 }, + { type: "label-stability", regions: "all" }, + ], + { perMarker: true }, + ), + ) +}) + +test("removing the first context member replaces the group once without overlapping following content", async ({ + page, +}, testInfo) => { + const ids = ["prt_key_01_read", "prt_key_02_glob", "prt_key_03_grep"] + const followingID = "prt_key_99_following" + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + toolPart(ids[0]!, "read", "completed", inputs.read), + toolPart(ids[1]!, "glob", "completed", inputs.glob), + toolPart(ids[2]!, "grep", "completed", inputs.grep), + textPart(followingID, "Following replaced group"), + ]), + ], + cpuRate: 4, + }) + const original = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`) + const originalRowKey = await original.evaluate((element) => + element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key"), + ) + await original.locator('[data-slot="collapsible-trigger"]').click() + await expect(original.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true") + const regions = defineVisualRegions({ + context: { + selector: '[data-timeline-part-ids*="prt_key_02_glob"]', + closest: '[data-timeline-row="AssistantPart"]', + }, + following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + }) + await startVisualProbe(page, regions) + await timeline.send( + event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: ids[0] }), + 500, + ) + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + "context-first-remove", + trace, + visualPlan(regions, [ + { type: "required", regions: ["context", "following"] }, + { type: "unique", regions: ["context", "following"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxPositionReversals: 0 }, + { type: "label-stability", regions: "all" }, + { type: "flow", regions: ["context", "following"] }, + ]), + ) + await expect(page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`)).toBeVisible() + expect( + await page + .locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`) + .evaluate((element) => element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key")), + ).toBe(originalRowKey) + await expect( + page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"] [data-slot="collapsible-trigger"]`), + ).toHaveAttribute("aria-expanded", "true") +}) diff --git a/packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts new file mode 100644 index 000000000000..3455438a7365 --- /dev/null +++ b/packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts @@ -0,0 +1,113 @@ +import { test } from "@playwright/test" +import { + defineVisualRegions, + reportVisualStability, + startVisualProbe, + stopVisualProbe, + visualPlan, +} from "../../utils/visual-stability" +import { + assistantMessage, + partUpdated, + setupTimeline, + shell, + textPart, + userMessage, + waitForVisualSettle, +} from "./fixture" + +// Fractional scaling exercises different browser rounding than the baseline. +for (const deviceScaleFactor of [1, 1.25]) { + test(`keeps shell growth ordered at device scale ${deviceScaleFactor}`, async ({ page }, testInfo) => { + const shellID = `prt_dpr_${String(deviceScaleFactor).replace(".", "_")}_01_shell` + const followingID = `prt_dpr_${String(deviceScaleFactor).replace(".", "_")}_02_following` + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([shell(shellID, "running"), textPart(followingID, "Following scaled shell")], { + completed: false, + }), + ], + settings: { shellToolPartsExpanded: true }, + cpuRate: 4, + deviceScaleFactor, + seedHistory: true, + }) + await waitForVisualSettle(page, [ + `[data-timeline-part-id="${shellID}"]`, + `[data-timeline-part-id="${followingID}"]`, + ]) + const regions = defineVisualRegions({ + shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + following: { + selector: `[data-timeline-part-id="${followingID}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, + }) + await startVisualProbe(page, regions) + await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 180) + await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 500) + const trace = await stopVisualProbe(page) + await reportVisualStability(testInfo, `dpr-${deviceScaleFactor}`, trace, shellPlan(regions)) + }) +} + +for (const reducedMotion of [true]) { + test(`keeps shell and status transitions ordered with reduced motion ${reducedMotion}`, async ({ + page, + }, testInfo) => { + const shellID = `prt_motion_${reducedMotion}_01_shell` + const followingID = `prt_motion_${reducedMotion}_02_following` + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([shell(shellID, "running"), textPart(followingID, "Following motion profile")], { + completed: false, + }), + ], + settings: { shellToolPartsExpanded: true }, + reducedMotion, + cpuRate: 4, + seedHistory: true, + }) + await waitForVisualSettle(page, [ + `[data-timeline-part-id="${shellID}"]`, + `[data-timeline-part-id="${followingID}"]`, + ]) + const regions = defineVisualRegions({ + shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + following: { + selector: `[data-timeline-part-id="${followingID}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, + }) + await startVisualProbe(page, regions) + await timeline.send(partUpdated(shell(shellID, "completed", lines(10))), 500) + const trace = await stopVisualProbe(page) + await reportVisualStability(testInfo, `reduced-motion-${reducedMotion}`, trace, shellPlan(regions)) + }) +} + +function lines(count: number) { + return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n") +} + +function shellPlan>( + regions: Regions & Record<"shell" | "following", { selector: string }>, +) { + return visualPlan( + regions, + [ + { type: "required", regions: ["shell", "following"] }, + { type: "unique", regions: ["shell", "following"] }, + { type: "stable", regions: ["shell", "following"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxPositionReversals: 0 }, + { type: "label-stability", regions: "all" }, + { type: "preserve-bottom-anchor" }, + { type: "flow", regions: ["shell", "following"] }, + ], + { perMarker: true }, + ) +} diff --git a/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts new file mode 100644 index 000000000000..e0f0d72233bd --- /dev/null +++ b/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts @@ -0,0 +1,121 @@ +import { test } from "@playwright/test" +import { + defineVisualRegions, + reportVisualStability, + startVisualProbe, + stopVisualProbe, + visualPlan, +} from "../../utils/visual-stability" +import { + assistantMessage, + partUpdated, + setupTimeline, + textPart, + toolPart, + userMessage, + waitForVisualSettle, +} from "./fixture" + +const profiles = [ + { name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } }, + { + name: "multi patch", + tool: "apply_patch", + input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] }, + }, +] as const + +for (const profile of profiles) { + test(`stabilizes ${profile.name} pending to completed`, async ({ page }, testInfo) => { + const partID = `prt_file_matrix_${profiles.indexOf(profile)}` + const followingID = `prt_file_matrix_following_${profiles.indexOf(profile)}` + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage( + [ + toolPart(partID, profile.tool, "pending", profile.input), + textPart(followingID, `Following ${profile.name}`), + ], + { completed: false }, + ), + ], + settings: { editToolPartsExpanded: true }, + cpuRate: 4, + }) + await waitForVisualSettle(page, [`[data-timeline-part-id="${partID}"]`, `[data-timeline-part-id="${followingID}"]`]) + const regions = defineVisualRegions({ + tool: { selector: `[data-timeline-part-id="${partID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + following: { + selector: `[data-timeline-part-id="${followingID}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, + }) + await startVisualProbe(page, regions) + await timeline.send(partUpdated(toolPart(partID, profile.tool, "running", profile.input)), 180) + await timeline.send(partUpdated(completedPart(partID, profile)), 900) + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + `file-${profile.name}`, + trace, + visualPlan( + regions, + [ + { type: "required", regions: ["tool", "following"] }, + { type: "unique", regions: ["tool", "following"] }, + { type: "stable", regions: ["tool", "following"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 }, + { type: "label-stability", regions: "all" }, + { type: "preserve-bottom-anchor" }, + { type: "flow", regions: ["tool", "following"] }, + ], + { perMarker: true }, + ), + ) + }) +} + +function completedPart(partID: string, profile: (typeof profiles)[number]) { + if (profile.tool === "edit") { + return toolPart(partID, profile.tool, "completed", profile.input, { + metadata: { + filediff: { + file: "src/edit.ts", + additions: 50, + deletions: 50, + before: source(50, false), + after: source(50, true), + }, + }, + }) + } + const files = [ + patchFile("src/a.ts", "update"), + patchFile("src/b.ts", "add"), + patchFile("src/old.ts", "delete"), + { ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" }, + ] + return toolPart(partID, profile.tool, "completed", profile.input, { metadata: { files } }) +} + +function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") { + return { + filePath, + relativePath: filePath, + type, + additions: type === "delete" ? 0 : 20, + deletions: type === "add" ? 0 : 20, + before: type === "add" ? undefined : source(20, false), + after: type === "delete" ? undefined : source(20, true), + } +} + +function source(count: number, changed: boolean) { + return Array.from( + { length: count }, + (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`, + ).join("") +} diff --git a/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts b/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts new file mode 100644 index 000000000000..798bf0df3b28 --- /dev/null +++ b/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts @@ -0,0 +1,113 @@ +import { expect, test } from "@playwright/test" +import { + defineVisualRegions, + reportVisualStability, + startVisualProbe, + stopVisualProbe, + visualPlan, +} from "../../utils/visual-stability" +import { + assistantMessage, + partUpdated, + setupTimeline, + textPart, + toolPart, + userMessage, + waitForVisualSettle, +} from "./fixture" + +test("adds patch files incrementally without resetting outer expansion", async ({ page }, testInfo) => { + const patchID = "prt_incremental_01_patch" + const followingID = "prt_incremental_02_following" + const first = patchFile("src/a.ts", "update") + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage( + [ + toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }), + textPart(followingID, "Following incremental patch"), + ], + { completed: false }, + ), + ], + settings: { editToolPartsExpanded: true }, + cpuRate: 4, + seedHistory: true, + }) + const trigger = page.locator(`[data-timeline-part-id="${patchID}"] [data-slot="collapsible-trigger"]`).first() + await expect(trigger).toHaveAttribute("aria-expanded", "true") + await waitForVisualSettle(page, [`[data-timeline-part-id="${patchID}"]`, `[data-timeline-part-id="${followingID}"]`]) + const regions = defineVisualRegions({ + patch: { selector: `[data-timeline-part-id="${patchID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + }) + await startVisualProbe(page, regions) + const second = patchFile("src/b.ts", "add") + const third = patchFile("src/old.ts", "delete") + await timeline.send( + partUpdated( + toolPart( + patchID, + "apply_patch", + "running", + { files: [first.filePath, second.filePath] }, + { metadata: { files: [first, second] } }, + ), + ), + 240, + ) + await timeline.send( + partUpdated( + toolPart( + patchID, + "apply_patch", + "completed", + { files: [first.filePath, second.filePath, third.filePath] }, + { metadata: { files: [first, second, third] } }, + ), + ), + 800, + ) + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + "incremental-patch", + trace, + visualPlan( + regions, + [ + { type: "required", regions: ["patch", "following"] }, + { type: "unique", regions: ["patch", "following"] }, + { type: "stable", regions: ["patch", "following"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: ["following"], maxPositionReversals: 0 }, + { type: "label-stability", regions: "all" }, + { type: "preserve-bottom-anchor" }, + { type: "flow", regions: ["patch", "following"] }, + ], + { perMarker: true }, + ), + ) + await expect(trigger).toHaveAttribute("aria-expanded", "true") + await expect(page.locator('[data-scope="apply-patch"] [data-type="delete"]')).toBeVisible() +}) + +function patchFile(filePath: string, type: "add" | "update" | "delete") { + return { + filePath, + relativePath: filePath, + type, + additions: type === "delete" ? 0 : 4, + deletions: type === "add" ? 0 : 3, + before: type === "add" ? undefined : source(false), + after: type === "delete" ? undefined : source(true), + } +} + +function source(changed: boolean) { + return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join( + "", + ) +} diff --git a/packages/app/e2e/performance/timeline-stability/fixture.test.ts b/packages/app/e2e/performance/timeline-stability/fixture.test.ts new file mode 100644 index 000000000000..b003645e640c --- /dev/null +++ b/packages/app/e2e/performance/timeline-stability/fixture.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test" +import { + assistantMessage, + event, + toolPart, + userMessage, + validateTimelineEvent, + validateTimelineMessages, + type PartSeed, +} from "./fixture" + +describe("timeline fixture validation", () => { + test("accepts a valid timeline", () => { + expect(validateTimelineMessages([userMessage(), assistantMessage()])).toHaveLength(2) + }) + + test("rejects malformed SDK values at runtime", () => { + expect(() => + assistantMessage([], { + error: { name: "APIError", data: { message: "failed" } } as never, + }), + ).toThrow() + expect(() => + validateTimelineEvent({ + directory: "C:/OpenCode/TimelineStability", + payload: { + id: "evt_invalid_status", + type: "session.status", + properties: { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } }, + }, + }), + ).toThrow() + }) + + test("rejects duplicate IDs and orphan assistants", () => { + expect(() => validateTimelineMessages([userMessage(), userMessage()])).toThrow(/duplicate message ID/) + expect(() => + validateTimelineMessages([userMessage(), assistantMessage([], { parentID: "msg_missing_parent" })]), + ).toThrow(/parent user/) + }) + + test("assigns deterministic event IDs", () => { + const first = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "busy" } }) + const second = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "idle" } }) + expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/) + expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1) + }) +}) + +if (false) { + const userSeed = { id: "prt_type_user", type: "text", text: "typed" } satisfies PartSeed<"user"> + userMessage([userSeed]) + + // @ts-expect-error Tool completion fields are not valid while pending. + toolPart("prt_invalid_pending", "bash", "pending", {}, { output: "impossible" }) + // @ts-expect-error Tool completion fields are not valid while running. + toolPart("prt_invalid_running", "bash", "running", {}, { output: "impossible" }) + // @ts-expect-error Tool error fields are not valid after completion. + toolPart("prt_invalid_completed", "bash", "completed", {}, { error: "impossible" }) + + assistantMessage([ + // @ts-expect-error Agent references belong to user messages, not assistant messages. + { id: "prt_invalid_owner", type: "agent", name: "explore", source: { value: "@explore", start: 0, end: 8 } }, + ]) + + // @ts-expect-error Retry status events require message and next. + event("session.status", { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } }) +} diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts new file mode 100644 index 000000000000..66edece4092e --- /dev/null +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -0,0 +1,568 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { Event } from "@opencode-ai/schema/event" +import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event" +import { SessionV1 } from "@opencode-ai/schema/session-v1" +import type { + AssistantMessage, + GlobalEvent, + Message, + Part, + Session, + SessionStatus, + ToolPart, + ToolState, + UserMessage, +} from "@opencode-ai/sdk/v2/client" +import { expect, type Page } from "@playwright/test" +import { Schema } from "effect" +import { mockOpenCodeServer } from "../../utils/mock-server" +import { installSseTransport } from "../../utils/sse-transport" +import { expectSessionTitle } from "../../utils/waits" + +export const directory = "C:/OpenCode/TimelineStability" +export const projectID = "proj_timeline_stability" +export const sessionID = "ses_timeline_stability" +export const userID = "msg_1000_timeline_user" +export const assistantID = "msg_1001_timeline_assistant" +export const title = "Timeline visual stability" +export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } + +type TimelinePayload = Extract< + GlobalEvent["payload"], + { + type: + | "message.updated" + | "message.removed" + | "message.part.updated" + | "message.part.removed" + | "message.part.delta" + | "session.status" + } +> + +type DeepReadonly = Value extends readonly unknown[] + ? { readonly [Key in keyof Value]: DeepReadonly } + : Value extends object + ? { readonly [Key in keyof Value]: DeepReadonly } + : Value + +export type TimelineEvent = DeepReadonly & { payload: TimelinePayload }> +export type EventPayload = TimelineEvent +export type ToolStatus = ToolState["status"] +export type TimelineMessage = { info: UserMessage; parts: Part[] } | { info: AssistantMessage; parts: Part[] } + +type UserPart = Extract +type AssistantPart = Exclude +type OwnedPart = Owner extends "user" ? UserPart : AssistantPart +export type PartSeed = + OwnedPart extends infer Candidate + ? Candidate extends Part + ? Omit + : never + : never + +type ToolOptions = State extends "pending" + ? { output?: never; title?: never; metadata?: never; error?: never } + : State extends "running" + ? { title?: string; metadata?: Record; output?: never; error?: never } + : State extends "error" + ? { error?: string; metadata?: Record; output?: never; title?: never } + : { output?: string; title?: string; metadata?: Record; error?: never } + +const decodeOptions = { errors: "all", onExcessProperty: "error" } as const +const decodeMessage = Schema.decodeUnknownSync(SessionV1.WithParts) +const decodePart = Schema.decodeUnknownSync(SessionV1.Part) +const decodeStatus = Schema.decodeUnknownSync(SessionStatusEvent.Info) +const timelineEventSchema = Schema.Union([ + eventSchema("message.updated", SessionV1.Event.MessageUpdated.data), + eventSchema("message.removed", SessionV1.Event.MessageRemoved.data), + eventSchema("message.part.updated", SessionV1.Event.PartUpdated.data), + eventSchema("message.part.removed", SessionV1.Event.PartRemoved.data), + eventSchema("message.part.delta", SessionV1.Event.PartDelta.data), + eventSchema("session.status", SessionStatusEvent.Status.data), +]) +const decodeEvent = Schema.decodeUnknownSync(timelineEventSchema) +let eventSequence = 0 + +export async function setupTimeline( + page: Page, + input: { + messages?: TimelineMessage[] + settings?: Record + sessions?: Session[] + cpuRate?: number + viewport?: { width: number; height: number } + eventRetry?: number + reducedMotion?: boolean + locale?: string + deviceScaleFactor?: number + seedHistory?: boolean + protocol?: "v1" | "v2" + } = {}, +) { + const sessions = input.sessions ?? [session()] + const messages = validateTimelineMessages([ + ...(input.seedHistory ? historyMessages(18) : []), + ...(input.messages ?? [userMessage(), assistantMessage()]), + ]) + const active = messages.findLast((message) => message.info.role === "assistant") + const initialStatus = decodeStatus( + active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" }, + decodeOptions, + ) + const transport = await installSseTransport(page, { + server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, + retry: input.eventRetry ?? 20, + }) + await mockOpenCodeServer(page, { + protocol: input.protocol, + directory, + project: project(), + provider: provider(), + sessions, + sessionStatus: { [sessionID]: initialStatus }, + pageMessages: () => ({ + items: messages, + }), + }) + await page.addInitScript((settings) => { + localStorage.setItem( + "settings.v3", + JSON.stringify({ + general: { + editToolPartsExpanded: false, + shellToolPartsExpanded: false, + showReasoningSummaries: false, + showSessionProgressBar: true, + ...settings, + }, + }), + ) + if (settings.newLayoutDesigns === false) { + localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" })) + } + }, input.settings ?? {}) + if (input.locale) { + await page.addInitScript((locale) => { + localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale })) + }, input.locale) + } + if (input.reducedMotion) await page.emulateMedia({ reducedMotion: "reduce" }) + await page.setViewportSize(input.viewport ?? { width: 1400, height: 900 }) + if (input.deviceScaleFactor) { + const devtools = await page.context().newCDPSession(page) + const viewport = input.viewport ?? { width: 1400, height: 900 } + await devtools.send("Emulation.setDeviceMetricsOverride", { + width: viewport.width, + height: viewport.height, + deviceScaleFactor: input.deviceScaleFactor, + mobile: false, + }) + } + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await transport.waitForConnection() + await expectSessionTitle(page, title) + if (input.cpuRate && input.cpuRate > 1) { + const devtools = await page.context().newCDPSession(page) + await devtools.send("Emulation.setCPUThrottlingRate", { rate: input.cpuRate }) + } + + return { + transport, + async send(event: TimelineEvent, delay = 0) { + const valid = validateTimelineEvent(event) + await transport.send(valid, { marker: describeEvent(valid) }) + if (delay) await page.waitForTimeout(delay) + }, + async sendAll(sequence: { event: TimelineEvent; delay: number }[]) { + for (const item of sequence) { + const valid = validateTimelineEvent(item.event) + await transport.send(valid, { marker: describeEvent(valid) }) + await page.waitForTimeout(item.delay) + } + }, + async settle(frames = 3) { + await page.evaluate( + (frames) => + new Promise((resolve) => { + let remaining = frames + const tick = () => { + remaining-- + if (remaining <= 0) return resolve() + requestAnimationFrame(tick) + } + requestAnimationFrame(tick) + }), + frames, + ) + }, + async waitForPart(partID: string) { + const part = page.locator(`[data-timeline-part-id="${partID}"]`) + await expect(part).toHaveCount(1) + await expect(part).toBeVisible() + }, + } +} + +function describeEvent(event: EventPayload) { + if (event.payload.type === "message.part.updated") { + const part = event.payload.properties.part + return [ + event.payload.type, + part.id, + part.type === "tool" ? part.tool : part.type, + part.type === "tool" ? part.state.status : undefined, + ] + .filter(Boolean) + .join(":") + } + if (event.payload.type === "session.status") { + const status = event.payload.properties.status + return [event.payload.type, status.type, status.type === "retry" ? status.attempt : undefined] + .filter((value) => value !== undefined) + .join(":") + } + return event.payload.type +} + +export function event( + type: Type, + properties: Extract["properties"], +): TimelineEvent +export function event(type: TimelinePayload["type"], properties: TimelinePayload["properties"]): TimelineEvent { + return validateTimelineEvent({ + directory, + payload: { id: `evt_timeline_${String(++eventSequence).padStart(4, "0")}`, type, properties }, + }) +} + +export function validateTimelineEvent(input: unknown): TimelineEvent { + return decodeEvent(input, decodeOptions) +} + +export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] { + input.forEach((message) => decodeMessage(message, decodeOptions)) + const messages = [...input] + const messageIDs = new Set() + const partIDs = new Set() + const users = new Set(messages.filter((message) => message.info.role === "user").map((message) => message.info.id)) + + messages.forEach((message) => { + if (messageIDs.has(message.info.id)) + throw new Error(`Timeline fixture has duplicate message ID: ${message.info.id}`) + messageIDs.add(message.info.id) + if (message.info.role === "assistant" && !users.has(message.info.parentID)) + throw new Error(`Timeline assistant ${message.info.id} must reference a parent user in the fixture`) + message.parts.forEach((part) => { + if (part.sessionID !== message.info.sessionID || part.messageID !== message.info.id) + throw new Error(`Timeline part ${part.id} ownership does not match message ${message.info.id}`) + if (message.info.role === "user" && !["text", "file", "agent", "subtask"].includes(part.type)) + throw new Error(`Timeline user message ${message.info.id} cannot own ${part.type} part ${part.id}`) + if (message.info.role === "assistant" && ["agent", "subtask"].includes(part.type)) + throw new Error(`Timeline assistant message ${message.info.id} cannot own ${part.type} part ${part.id}`) + if (partIDs.has(part.id)) throw new Error(`Timeline fixture has duplicate part ID: ${part.id}`) + partIDs.add(part.id) + }) + }) + return messages +} + +export async function waitForVisualSettle(page: Page, selectors: string[], stableFrames = 3) { + await page.waitForFunction( + ({ selectors, stableFrames }) => { + const elements = selectors.map((selector) => document.querySelector(selector)) + if (elements.some((element) => !element)) return false + return new Promise((resolve) => { + let stable = 0 + let previous = "" + const sample = () => { + const signature = JSON.stringify( + elements.map((element) => { + const rect = element!.getBoundingClientRect() + return [Math.round(rect.top * 10), Math.round(rect.bottom * 10), Math.round(rect.height * 10)] + }), + ) + stable = signature === previous ? stable + 1 : 0 + previous = signature + const ordered = elements + .slice(1) + .every( + (element, index) => + elements[index]!.getBoundingClientRect().bottom <= element!.getBoundingClientRect().top + 0.5, + ) + if (stable >= stableFrames && ordered) return resolve(true) + requestAnimationFrame(sample) + } + requestAnimationFrame(sample) + }) + }, + { selectors, stableFrames }, + ) +} + +export function historyMessages(count: number): TimelineMessage[] { + return Array.from({ length: count }, (_, index) => { + const value = String(index).padStart(4, "0") + const historyUserID = `msg_0${value}_history_a_user` + return [ + userMessage(undefined, { id: historyUserID, created: 1690000000000 + index * 10_000 }), + assistantMessage( + [ + { + id: `prt_0${value}_history_text`, + type: "text", + text: `Historical response ${index}. ${"Existing session content keeps the virtual timeline realistic. ".repeat(5)}`, + }, + ], + { + id: `msg_0${value}_history_b_assistant`, + parentID: historyUserID, + created: 1690000001000 + index * 10_000, + }, + ), + ] + }).flat() +} + +export function partUpdated(part: Part | PartSeed<"assistant">) { + const owned = "messageID" in part ? part : { ...part, sessionID, messageID: assistantID } + decodePart(owned, decodeOptions) + return event("message.part.updated", { + sessionID, + part: owned, + time: 1700000002000, + }) +} + +export function partDelta(partID: string, delta: string, messageID = assistantID) { + return event("message.part.delta", { sessionID, messageID, partID, field: "text", delta }) +} + +export function messageUpdated(info: Message) { + return event("message.updated", { sessionID, info }) +} + +export function status(type: SessionStatus["type"], attempt = 1) { + return event("session.status", { + sessionID, + status: type === "retry" ? { type, attempt, message: "Rate limited", next: 1700000010000 } : { type }, + }) +} + +export function userMessage( + parts?: PartSeed<"user">[], + input: { id?: string; summary?: UserMessage["summary"]; created?: number } = {}, +): Extract { + const id = input.id ?? userID + const seeds = parts ?? [userText("Build the timeline stability matrix.", { id: `prt_${id}_text` })] + const message = { + info: { + id, + sessionID, + role: "user", + time: { created: input.created ?? 1700000000000 }, + summary: input.summary ?? { diffs: [] }, + agent: "build", + model, + }, + parts: seeds.map((part) => ({ + ...part, + sessionID, + messageID: id, + })), + } satisfies Extract + decodeMessage(message, decodeOptions) + return message +} + +export function assistantMessage( + parts: PartSeed<"assistant">[] = [], + input: { + id?: string + parentID?: string + completed?: boolean + error?: AssistantMessage["error"] + created?: number + } = {}, +): Extract { + const id = input.id ?? assistantID + const message = { + info: { + id, + sessionID, + role: "assistant", + time: { + created: input.created ?? 1700000001000, + ...(input.completed === false ? {} : { completed: (input.created ?? 1700000001000) + 1_000 }), + }, + parentID: input.parentID ?? userID, + modelID: model.modelID, + providerID: model.providerID, + mode: "build", + agent: "build", + path: { cwd: directory, root: directory }, + cost: 0.01, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + variant: "max", + ...(input.error ? { error: input.error } : {}), + }, + parts: parts.map((part) => ({ ...part, sessionID, messageID: id })), + } satisfies Extract + decodeMessage(message, decodeOptions) + return message +} + +export function userText( + text: string, + input: Partial, { type: "text" }>, "type" | "text">> = {}, +): Extract, { type: "text" }> { + return { id: "prt_user_text", type: "text", text, ...input } +} + +export function textPart(id: string, text: string): Extract, { type: "text" }> { + return { id, type: "text", text } +} + +export function reasoningPart(id: string, text: string): Extract, { type: "reasoning" }> { + return { id, type: "reasoning", text, time: { start: 1700000001000 } } +} + +export function toolPart( + id: string, + tool: string, + state: "pending", + input: Record, + options?: ToolOptions<"pending">, +): Omit +export function toolPart( + id: string, + tool: string, + state: "running", + input: Record, + options?: ToolOptions<"running">, +): Omit +export function toolPart( + id: string, + tool: string, + state: "completed", + input: Record, + options?: ToolOptions<"completed">, +): Omit +export function toolPart( + id: string, + tool: string, + state: "error", + input: Record, + options?: ToolOptions<"error">, +): Omit +export function toolPart( + id: string, + tool: string, + state: ToolStatus, + input: Record, + options: ToolOptions = {}, +): Omit { + const base = { id, type: "tool" as const, callID: `call_${id}`, tool } + if (state === "pending") return { ...base, state: { status: state, input, raw: "" } } + if (state === "running") + return { + ...base, + state: { + status: state, + input, + title: options.title, + metadata: options.metadata ?? {}, + time: { start: 1700000001000 }, + }, + } + if (state === "error") + return { + ...base, + state: { + status: state, + input, + error: options.error ?? "Tool failed", + metadata: options.metadata ?? {}, + time: { start: 1700000001000, end: 1700000002000 }, + }, + } + return { + ...base, + state: { + status: state, + input, + output: options.output ?? "Completed", + title: options.title ?? tool, + metadata: options.metadata ?? {}, + time: { start: 1700000001000, end: 1700000002000 }, + }, + } +} + +export function shell( + id: string, + state: ToolStatus, + output = "", + command = `echo ${id}`, +): Omit { + if (state === "pending") return toolPart(id, "bash", state, { command }) + if (state === "running") + return toolPart(id, "bash", state, { command }, { title: command, metadata: { command, output } }) + if (state === "error") + return toolPart(id, "bash", state, { command }, { error: output || undefined, metadata: { command, output } }) + return toolPart(id, "bash", state, { command }, { title: command, output, metadata: { command, output } }) +} + +export function completedAssistantInfo(info: AssistantMessage): AssistantMessage { + return { ...info, time: { ...info.time, completed: 1700000003000 } } +} + +export function project() { + return { + id: projectID, + worktree: directory, + vcs: "git", + name: "timeline-stability", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + } +} + +export function session(input: Partial = {}): Session { + return { + id: sessionID, + slug: "timeline-stability", + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + ...input, + } +} + +function eventSchema< + const Type extends TimelinePayload["type"], + const Properties extends Schema.Codec, +>(type: Type, properties: Properties) { + return Schema.Struct({ + directory: Schema.String, + project: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), + payload: Schema.Struct({ id: Event.ID, type: Schema.Literal(type), properties }), + }) +} + +function provider() { + return { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "claude-opus-4-6" }, + } +} diff --git a/packages/app/e2e/performance/timeline-stability/interaction.spec.ts b/packages/app/e2e/performance/timeline-stability/interaction.spec.ts new file mode 100644 index 000000000000..8cdf4fa8cfa8 --- /dev/null +++ b/packages/app/e2e/performance/timeline-stability/interaction.spec.ts @@ -0,0 +1,242 @@ +import { expect, test } from "@playwright/test" +import { + defineVisualRegions, + reportVisualStability, + startVisualProbe, + stopVisualProbe, + visualPlan, +} from "../../utils/visual-stability" +import { assistantMessage, setupTimeline, shell, textPart, toolPart, userMessage, waitForVisualSettle } from "./fixture" + +test("expands and collapses a long completed shell without overlap", async ({ page }, testInfo) => { + const shellID = "prt_interaction_01_shell" + const followingID = "prt_interaction_02_following" + await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([shell(shellID, "completed", lines(50)), textPart(followingID, "Following shell expansion")]), + ], + settings: { shellToolPartsExpanded: false }, + cpuRate: 4, + seedHistory: true, + }) + const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`) + await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`]) + const regions = defineVisualRegions({ + shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + }) + const plan = visualPlan(regions, [ + { type: "required", regions: ["shell", "following"] }, + { type: "unique", regions: ["shell", "following"] }, + { type: "stable", regions: ["shell", "following"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxPositionReversals: 0 }, + { type: "label-stability", regions: "all" }, + { type: "preserve-bottom-anchor" }, + { type: "flow", regions: ["shell", "following"] }, + ]) + await startVisualProbe(page, regions) + await trigger.click() + await expect(trigger).toHaveAttribute("aria-expanded", "true") + await page.waitForTimeout(500) + const expanded = await stopVisualProbe(page) + await reportVisualStability(testInfo, "shell-expand", expanded, plan) + + await startVisualProbe(page, regions) + await trigger.click() + await expect(trigger).toHaveAttribute("aria-expanded", "false") + await page.waitForTimeout(500) + const collapsed = await stopVisualProbe(page) + await reportVisualStability(testInfo, "shell-collapse", collapsed, plan) +}) + +test("expands and collapses a completed context group without overlap", async ({ page }, testInfo) => { + const ids = [ + "prt_interaction_01_read", + "prt_interaction_02_glob", + "prt_interaction_03_grep", + "prt_interaction_04_list", + ] + const group = `[data-timeline-part-ids="${ids.join(",")}"]` + const followingID = "prt_interaction_context_following" + await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }), + toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }), + toolPart(ids[2]!, "grep", "completed", { path: ".", pattern: "stable" }), + toolPart(ids[3]!, "list", "completed", { path: "src" }), + textPart(followingID, "Following context expansion"), + ]), + ], + cpuRate: 4, + seedHistory: true, + }) + const trigger = page.locator(`${group} [data-slot="collapsible-trigger"]`) + await waitForVisualSettle(page, [group, `[data-timeline-part-id="${followingID}"]`]) + for (const [name, expanded] of [ + ["context-expand", true], + ["context-collapse", false], + ["context-reexpand", true], + ] as const) { + const regions = defineVisualRegions({ + context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' }, + following: { + selector: `[data-timeline-part-id="${followingID}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, + }) + await startVisualProbe(page, regions) + await trigger.click() + await expect(trigger).toHaveAttribute("aria-expanded", String(expanded)) + await page.waitForTimeout(500) + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + name, + trace, + visualPlan(regions, [ + { type: "required", regions: ["context", "following"] }, + { type: "unique", regions: ["context", "following"] }, + { type: "stable", regions: ["context", "following"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxPositionReversals: 0 }, + { type: "label-stability", regions: "all" }, + { type: "preserve-bottom-anchor" }, + { type: "flow", regions: ["context", "following"] }, + ]), + ) + } +}) + +test("expands and collapses an edit diff without moving twice", async ({ page }, testInfo) => { + const editID = "prt_interaction_edit" + const followingID = "prt_interaction_edit_following" + await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + toolPart( + editID, + "edit", + "completed", + { filePath: "src/edit.ts" }, + { + metadata: { + filediff: { + file: "src/edit.ts", + additions: 40, + deletions: 40, + before: source(40, false), + after: source(40, true), + }, + }, + }, + ), + textPart(followingID, "Following edit expansion"), + ]), + ], + settings: { editToolPartsExpanded: false }, + cpuRate: 4, + seedHistory: true, + }) + const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first() + await waitForVisualSettle(page, [`[data-timeline-part-id="${editID}"]`, `[data-timeline-part-id="${followingID}"]`]) + const regions = defineVisualRegions({ + edit: { selector: `[data-timeline-part-id="${editID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + }) + await startVisualProbe(page, regions) + await trigger.click() + await expect(trigger).toHaveAttribute("aria-expanded", "true") + await page.waitForTimeout(900) + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + "edit-expand", + trace, + visualPlan(regions, [ + { type: "required", regions: ["edit", "following"] }, + { type: "unique", regions: ["edit", "following"] }, + { type: "stable", regions: ["edit", "following"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 }, + { type: "label-stability", regions: "all" }, + { type: "preserve-bottom-anchor" }, + { type: "flow", regions: ["edit", "following"] }, + ]), + ) +}) + +test("shows all and expands historical diff summary without overlap", async ({ page }, testInfo) => { + const firstUser = userMessage(undefined, { + summary: { + diffs: Array.from({ length: 12 }, (_, index) => ({ + file: `src/diff-${index}.ts`, + additions: 1, + deletions: 1, + patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`, + })), + }, + }) + const nextUserID = "msg_2000_diff_interaction_user" + await setupTimeline(page, { + messages: [ + firstUser, + assistantMessage(), + userMessage(undefined, { id: nextUserID, created: 1700000010000 }), + assistantMessage([], { + id: "msg_2001_diff_interaction_assistant", + parentID: nextUserID, + created: 1700000011000, + }), + ], + cpuRate: 4, + }) + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + await scroller.evaluate((element) => (element.scrollTop = 0)) + const diff = page.locator('[data-timeline-row="DiffSummary"]') + const following = page.locator(`[data-message-id="${nextUserID}"]`).first() + await expect(diff).toBeVisible() + const regions = defineVisualRegions({ + diff: { selector: '[data-timeline-row="DiffSummary"]' }, + following: { selector: `[data-message-id="${nextUserID}"]` }, + }) + await startVisualProbe(page, regions) + await page.getByText(/show all/i).click() + await page.waitForTimeout(500) + await diff.locator('[data-slot="session-turn-diff-trigger"]').first().click() + await page.waitForTimeout(900) + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + "diff-summary-expand", + trace, + visualPlan(regions, [ + { type: "required", regions: ["diff", "following"] }, + { type: "unique", regions: ["diff", "following"] }, + { type: "stable", regions: ["diff", "following"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxPositionReversals: 1, maxReversals: 2 }, + { type: "label-stability", regions: "all" }, + { type: "flow", regions: ["diff", "following"] }, + ]), + ) +}) + +function lines(count: number) { + return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n") +} + +function source(count: number, changed: boolean) { + return Array.from( + { length: count }, + (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`, + ).join("") +} diff --git a/packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts b/packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts new file mode 100644 index 000000000000..40688429a33e --- /dev/null +++ b/packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts @@ -0,0 +1,157 @@ +import { expect, test } from "@playwright/test" +import { + defineVisualRegions, + mapVisualRegions, + reportVisualStability, + startVisualProbe, + stopVisualProbe, + visualPlan, +} from "../../utils/visual-stability" +import { + assistantMessage, + completedAssistantInfo, + messageUpdated, + partDelta, + partUpdated, + reasoningPart, + setupTimeline, + shell, + status, + textPart, + userMessage, + waitForVisualSettle, +} from "./fixture" + +test.describe("timeline visual lifecycle stability", () => { + test("streams empty, short, and long parallel shells to staggered completion", async ({ page }, testInfo) => { + test.setTimeout(180_000) + const ids = ["prt_parallel_01_empty", "prt_parallel_02_short", "prt_parallel_03_long"] as const + const initial = ids.map((id) => shell(id, "running")) + const followingID = "prt_parallel_04_following" + const assistant = assistantMessage([...initial, textPart(followingID, "Following all parallel shells.")], { + completed: false, + }) + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistant], + settings: { shellToolPartsExpanded: true, showReasoningSummaries: true }, + cpuRate: 4, + eventRetry: 24, + seedHistory: true, + }) + await timeline.send(status("busy"), 150) + for (const id of ids) await timeline.waitForPart(id) + const scroller = page.locator(".scroll-view__viewport", { + has: page.locator('[data-timeline-row="AssistantPart"]'), + }) + await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight)) + const regions = defineVisualRegions({ + prt_shell_empty: shellRegion(ids[0]), + prt_shell_short: shellRegion(ids[1]), + prt_shell_long: shellRegion(ids[2]), + following: shellRegion(followingID), + }) + await waitForVisualSettle(page, [`[data-timeline-part-id="${followingID}"]`]) + await startVisualProbe(page, regions) + await timeline.sendAll([ + { event: partUpdated(shell(ids[0]!, "completed", "")), delay: 180 }, + { event: partUpdated(shell(ids[2]!, "running", lines(10))), delay: 70 }, + { event: partUpdated(shell(ids[1]!, "running", lines(2))), delay: 110 }, + { event: partUpdated(shell(ids[2]!, "running", lines(25))), delay: 80 }, + { event: partUpdated(shell(ids[1]!, "completed", lines(2))), delay: 260 }, + { event: partUpdated(shell(ids[2]!, "running", lines(50))), delay: 100 }, + { event: partUpdated(shell(ids[2]!, "completed", lines(50))), delay: 450 }, + { event: messageUpdated(completedAssistantInfo(assistant.info)), delay: 100 }, + { event: status("idle"), delay: 700 }, + ]) + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + "parallel-shells", + trace, + visualPlan( + regions, + [ + { type: "required", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] }, + { type: "unique", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long"] }, + { type: "stable", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 4 }, + { type: "label-stability", regions: "all" }, + { type: "preserve-bottom-anchor" }, + { type: "flow", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] }, + ], + { perMarker: true }, + ), + ) + await expect(page.locator(`[data-timeline-part-id="${ids[2]}"] [data-slot="bash-pre"]`)).toContainText("line 50") + + const short = page.locator(`[data-timeline-part-id="${ids[1]}"]`) + await short.locator('[data-slot="collapsible-trigger"]').click() + await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false") + await timeline.send(partUpdated(textPart("prt_late_sibling", "A later sibling rerender.")), 250) + await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false") + }) + + test("replaces thinking with streamed reasoning and text without a blank visible turn", async ({ + page, + }, testInfo) => { + const reasoningID = "prt_reasoning_visible" + const textID = "prt_streamed_text" + const assistant = assistantMessage([], { completed: false }) + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistant], + settings: { showReasoningSummaries: true }, + cpuRate: 4, + }) + await timeline.send(status("busy"), 120) + await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() + + const regions = defineVisualRegions({ + thinking: { selector: '[data-timeline-row="Thinking"]' }, + reasoning: { + selector: `[data-timeline-part-id="${reasoningID}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, + text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + }) + await startVisualProbe(page, regions) + await timeline.send(partUpdated(reasoningPart(reasoningID, "")), 100) + await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0) + await timeline.send(partUpdated(reasoningPart(reasoningID, "## Planning\n\nChecking the visible timeline.")), 160) + await timeline.waitForPart(reasoningID) + await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await timeline.send(partUpdated(textPart(textID, "Starting")), 100) + await timeline.send(partDelta(textID, " **stable"), 90) + await timeline.send(partDelta(textID, " output** with `code` and [a link"), 130) + await timeline.send(partDelta(textID, "](https://example.com)."), 220) + await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 120) + await timeline.send(status("idle"), 500) + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + "reasoning-text-handoff", + trace, + visualPlan(regions, [ + { type: "required", regions: ["reasoning", "text"] }, + { type: "continuous-any", regions: ["thinking", "reasoning", "text"] }, + { type: "unique", regions: ["reasoning", "text"] }, + { type: "stable", regions: ["reasoning", "text"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxReversals: 4 }, + { type: "label-stability", regions: "all" }, + { type: "flow", regions: ["reasoning", "text"] }, + ]), + ) + await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("stable output") + }) +}) + +function lines(count: number) { + return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n") +} + +function shellRegion(id: string) { + return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' } +} diff --git a/packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts b/packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts new file mode 100644 index 000000000000..7891eba5bd97 --- /dev/null +++ b/packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts @@ -0,0 +1,75 @@ +import { expect, test } from "@playwright/test" +import { + analyzeVisualObservations, + defineVisualRegions, + startVisualProbe, + stopVisualProbe, + visualPlan, +} from "../../utils/visual-stability" +import { assistantMessage, setupTimeline, textPart, userMessage } from "./fixture" + +test("detects blanking caused by ancestor opacity", async ({ page }) => { + const partID = "prt_oracle_ancestor_opacity" + await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] }) + const row = page.locator(`[data-timeline-part-id="${partID}"]`).first() + const regions = defineVisualRegions({ + content: { selector: `[data-timeline-part-id="${partID}"]` }, + }) + await startVisualProbe(page, regions) + await row.evaluate((element) => { + element.parentElement!.style.opacity = "0" + }) + await page.waitForTimeout(50) + await row.evaluate((element) => { + element.parentElement!.style.opacity = "1" + }) + await page.waitForTimeout(50) + const trace = await stopVisualProbe(page) + const issues = analyzeVisualObservations( + trace.samples, + visualPlan(regions, [ + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all" }, + { type: "label-stability", regions: "all" }, + ]), + ) + + expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true) +}) + +test("detects root opacity when probing descendant opacity", async ({ page }) => { + const partID = "prt_oracle_descendant_opacity" + await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] }) + const row = page.locator(`[data-timeline-part-id="${partID}"]`).first() + await row.evaluate((element) => { + element.innerHTML = 'Visible content' + }) + const regions = defineVisualRegions({ + content: { + selector: `[data-timeline-part-id="${partID}"]`, + opacitySelectors: ['[data-probe-opacity="true"]'], + }, + }) + await startVisualProbe(page, regions) + await row.evaluate((element) => { + ;(element as HTMLElement).style.opacity = "0" + }) + await page.waitForTimeout(50) + await row.evaluate((element) => { + ;(element as HTMLElement).style.opacity = "1" + }) + await page.waitForTimeout(50) + const trace = await stopVisualProbe(page) + const issues = analyzeVisualObservations( + trace.samples, + visualPlan(regions, [ + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all" }, + { type: "label-stability", regions: "all" }, + ]), + ) + + expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true) +}) diff --git a/packages/app/e2e/performance/timeline-stability/playwright.config.ts b/packages/app/e2e/performance/timeline-stability/playwright.config.ts new file mode 100644 index 000000000000..cfa4d926132d --- /dev/null +++ b/packages/app/e2e/performance/timeline-stability/playwright.config.ts @@ -0,0 +1,17 @@ +import config from "../playwright.config" + +export default { + ...config, + testDir: ".", + testMatch: "**/*.spec.ts", + outputDir: "../../test-results/timeline-stability", + reporter: [["html", { outputFolder: "../../playwright-report/timeline-stability", open: "never" }], ["line"]], + retries: 0, + workers: 1, + use: { + ...config.use, + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, +} diff --git a/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts b/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts new file mode 100644 index 000000000000..e67545104d48 --- /dev/null +++ b/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts @@ -0,0 +1,353 @@ +import { expect, test } from "@playwright/test" +import { + defineVisualRegions, + reportVisualStability, + startVisualProbe, + stopVisualProbe, + visualPlan, +} from "../../utils/visual-stability" +import { + assistantMessage, + partUpdated, + setupTimeline, + shell, + textPart, + userMessage, + type TimelineMessage, +} from "./fixture" + +test("does not reverse visible rows when the user wheels during shell remeasurement", async ({ page }, testInfo) => { + const shellID = "prt_wheel_01_shell" + const followingID = "prt_wheel_02_following" + const timeline = await setupTimeline(page, { + messages: [ + ...history(12), + userMessage(), + assistantMessage([shell(shellID, "running"), textPart(followingID, "Following wheel interaction")], { + completed: false, + }), + ], + settings: { shellToolPartsExpanded: true }, + cpuRate: 4, + reducedMotion: true, + seedHistory: true, + }) + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + const regions = defineVisualRegions({ + shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + }) + await startVisualProbe(page, regions) + await timeline.send(partUpdated(shell(shellID, "running", lines(30))), 80) + await scroller.evaluate((element) => + element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -180 })), + ) + await scroller.evaluate((element) => (element.scrollTop -= 180)) + await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 250) + const trace = await stopVisualProbe(page) + await reportVisualStability(testInfo, "wheel-during-resize", trace, rowPairPlan(regions, 1)) +}) + +test("keeps moving upward while drag-selecting above the timeline", async ({ page }) => { + await setupTimeline(page, { + messages: history(80), + viewport: { width: 1400, height: 700 }, + reducedMotion: true, + }) + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + const text = page.getByText("History 79.", { exact: false }) + await expect(text).toBeVisible() + await scroller.evaluate((element) => { + element.dataset.selectionLength = "0" + document.addEventListener("selectionchange", () => { + element.dataset.selectionLength = String( + Math.max(Number(element.dataset.selectionLength), window.getSelection()?.toString().length ?? 0), + ) + }) + }) + const textBox = await text.boundingBox() + const scrollBox = await scroller.boundingBox() + expect(textBox).not.toBeNull() + expect(scrollBox).not.toBeNull() + if (!textBox || !scrollBox) return + + await page.mouse.move(textBox.x + textBox.width - 10, textBox.y + textBox.height / 2) + await page.mouse.down() + await page.mouse.move(textBox.x + 20, scrollBox.y - 120, { steps: 30 }) + + await expect.poll(() => scroller.evaluate((element) => Number(element.dataset.selectionLength))).toBeGreaterThan(0) + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeGreaterThan(500) + await page.mouse.up() +}) + +test("does not pull a keyboard-scrolled user during shell remeasurement", async ({ page }, testInfo) => { + const shellID = "prt_keyboard_01_shell" + const followingID = "prt_keyboard_02_following" + const timeline = await setupTimeline(page, { + messages: [ + ...history(12), + userMessage(), + assistantMessage([shell(shellID, "running"), textPart(followingID, "Following keyboard interaction")], { + completed: false, + }), + ], + settings: { shellToolPartsExpanded: true }, + cpuRate: 4, + }) + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + await scroller.focus() + for (let index = 0; index < 3; index++) { + await scroller.press("PageUp") + await page.waitForTimeout(250) + } + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop), { + timeout: 20_000, + }) + .toBeGreaterThan(80) + await page.waitForFunction(() => { + const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => + element.querySelector("[data-timeline-row]"), + ) + if (!root) return false + return new Promise((resolve) => { + const top = root.scrollTop + requestAnimationFrame(() => requestAnimationFrame(() => resolve(Math.abs(root.scrollTop - top) <= 0.5))) + }) + }) + const anchor = await scroller.evaluate((element) => { + const view = element.getBoundingClientRect() + return [...element.querySelectorAll("[data-timeline-key]")].find((row) => { + const rect = row.getBoundingClientRect() + return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40 + })?.dataset.timelineKey + }) + expect(anchor).toBeTruthy() + const regions = defineVisualRegions({ + anchor: { selector: `[data-timeline-key="${anchor}"]` }, + }) + await startVisualProbe(page, regions) + await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 400) + const trace = await stopVisualProbe(page) + await reportVisualStability(testInfo, "keyboard-during-resize", trace, anchorPlan(regions)) +}) + +test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => { + const shellID = "prt_descendant_keyboard_01_shell" + const timeline = await setupTimeline(page, { + messages: [...history(12), userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])], + settings: { shellToolPartsExpanded: false }, + cpuRate: 4, + reducedMotion: true, + }) + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + const row = page.locator(`[data-timeline-part-id="${shellID}"]`).first() + const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`) + await row.evaluate((element) => element.setAttribute("tabindex", "0")) + await row.focus() + for (let index = 0; index < 3; index++) { + await row.press("PageUp") + await page.waitForTimeout(250) + } + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeGreaterThan(5) + const anchor = await scroller.evaluate((element) => { + const view = element.getBoundingClientRect() + return [...element.querySelectorAll("[data-timeline-key]")].find((row) => { + const rect = row.getBoundingClientRect() + return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40 + })?.dataset.timelineKey + }) + expect(anchor).toBeTruthy() + const regions = defineVisualRegions({ + anchor: { selector: `[data-timeline-key="${anchor}"]` }, + }) + await startVisualProbe(page, regions) + await trigger.click() + await page.waitForTimeout(300) + const trace = await stopVisualProbe(page) + await reportVisualStability(testInfo, "descendant-keyboard-resize", trace, anchorPlan(regions)) +}) + +test("does not claim keyboard scrolling owned by a nested scrollable", async ({ page }) => { + const shellID = "prt_nested_keyboard_shell" + await setupTimeline(page, { + messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(50))])], + settings: { shellToolPartsExpanded: true }, + cpuRate: 4, + reducedMotion: true, + seedHistory: true, + }) + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + const nested = page.locator(`[data-timeline-part-id="${shellID}"] [data-scrollable]`) + await nested.evaluate((element) => (element.scrollTop = element.scrollHeight)) + await nested.focus() + await page.waitForFunction(() => { + const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => + element.querySelector("[data-timeline-row]"), + ) + if (!root) return false + return new Promise((resolve) => { + const top = root.scrollTop + requestAnimationFrame(() => requestAnimationFrame(() => resolve(Math.abs(root.scrollTop - top) <= 0.5))) + }) + }) + const before = await scroller.evaluate((element) => element.scrollTop) + const nestedBefore = await nested.evaluate((element) => element.scrollTop) + await nested.press("PageUp") + await page.waitForTimeout(300) + expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before) + expect(await nested.evaluate((element) => element.scrollTop)).toBeLessThan(nestedBefore) + + await nested.evaluate((element) => (element.scrollTop = 0)) + await scroller.evaluate((element) => (element.scrollTop = Math.min(300, element.scrollHeight - element.clientHeight))) + const boundaryBefore = await scroller.evaluate((element) => element.scrollTop) + expect(boundaryBefore).toBeGreaterThan(0) + await nested.press("PageUp") + await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(boundaryBefore) + + const nonOverflowing = page.locator(`[data-timeline-part-id="${shellID}"]`).first() + await nonOverflowing.evaluate((element) => { + element.setAttribute("data-scrollable", "") + element.setAttribute("tabindex", "0") + }) + await nonOverflowing.focus() + const nonOverflowBefore = await scroller.evaluate((element) => element.scrollTop) + await nonOverflowing.press("PageUp") + await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(nonOverflowBefore) +}) + +test("jump to latest lands on stable final rows after offscreen growth", async ({ page }, testInfo) => { + const shellID = "prt_jump_01_shell" + const followingID = "prt_jump_02_following" + const timeline = await setupTimeline(page, { + messages: [ + ...history(20), + userMessage(), + assistantMessage([shell(shellID, "running"), textPart(followingID, "Latest visible row")], { completed: false }), + ], + settings: { shellToolPartsExpanded: true }, + cpuRate: 4, + }) + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + await scroller.evaluate( + (element) => (element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 600)), + ) + await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 300) + const regions = defineVisualRegions({ + shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + }) + await startVisualProbe(page, regions) + await page.getByRole("button", { name: /Jump to latest/i }).click() + await expect(page.locator(`[data-timeline-part-id="${followingID}"]`)).toBeVisible() + await page.waitForTimeout(600) + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + "jump-latest", + trace, + visualPlan(regions, [ + { type: "required", regions: ["shell", "following"] }, + { type: "unique", regions: ["shell", "following"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxPositionReversals: 1 }, + { type: "label-stability", regions: "all" }, + { type: "acquire-bottom-anchor" }, + { type: "flow", regions: ["shell", "following"] }, + ]), + ) +}) + +test("handles a single row taller than the viewport", async ({ page }, testInfo) => { + const shellID = "prt_tall_01_shell" + const followingID = "prt_tall_02_following" + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([shell(shellID, "running"), textPart(followingID, "After tall row")], { completed: false }), + ], + settings: { shellToolPartsExpanded: true }, + viewport: { width: 900, height: 360 }, + cpuRate: 4, + seedHistory: true, + }) + const regions = defineVisualRegions({ + shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + }) + await startVisualProbe(page, regions) + await timeline.send(partUpdated(shell(shellID, "completed", lines(100))), 700) + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + "taller-than-viewport", + trace, + visualPlan(regions, [ + { type: "required", regions: ["shell", "following"] }, + { type: "unique", regions: ["shell", "following"] }, + { type: "stable", regions: ["shell", "following"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxPositionReversals: 0 }, + { type: "label-stability", regions: "all" }, + { type: "preserve-bottom-anchor" }, + { type: "flow", regions: ["shell", "following"] }, + ]), + ) +}) + +function history(count: number): TimelineMessage[] { + return Array.from({ length: count }, (_, index) => { + const prefix = `msg_${String(index).padStart(4, "0")}_scroll` + const userID = `${prefix}_a_user` + return [ + userMessage(undefined, { id: userID, created: 1690000000000 + index * 10_000 }), + assistantMessage( + [textPart(`prt_${String(index).padStart(4, "0")}_scroll`, `History ${index}. ${"content ".repeat(30)}`)], + { + id: `${prefix}_b_assistant`, + parentID: userID, + created: 1690000001000 + index * 10_000, + }, + ), + ] + }).flat() +} + +function lines(count: number) { + return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n") +} + +function rowPairPlan( + regions: Record<"shell" | "following", { selector: string; closest?: string }>, + maxPositionReversals: number, +) { + return visualPlan(regions, [ + { type: "required", regions: ["shell", "following"] }, + { type: "unique", regions: ["shell", "following"] }, + { type: "stable", regions: ["shell", "following"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxPositionReversals }, + { type: "label-stability", regions: "all" }, + { type: "flow", regions: ["shell", "following"] }, + ]) +} + +function anchorPlan(regions: Record<"anchor", { selector: string; closest?: string }>) { + return visualPlan(regions, [ + { type: "required", regions: ["anchor"] }, + { type: "unique", regions: ["anchor"] }, + { type: "stable", regions: ["anchor"] }, + { type: "fixed", regions: ["anchor"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxPositionReversals: 0 }, + { type: "label-stability", regions: "all" }, + ]) +} diff --git a/packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts new file mode 100644 index 000000000000..bab1ca23b4f9 --- /dev/null +++ b/packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts @@ -0,0 +1,255 @@ +import { test } from "@playwright/test" +import { + defineVisualRegions, + reportVisualStability, + startVisualProbe, + stopVisualProbe, + visualPlan, +} from "../../utils/visual-stability" +import { + assistantMessage, + partUpdated, + setupTimeline, + shell, + textPart, + userMessage, + waitForVisualSettle, +} from "./fixture" + +const profiles = [ + { + name: "empty running to completed", + updates: [{ state: "completed" as const, output: "", delay: 350 }], + }, + { + name: "50 lines arriving incrementally", + updates: [ + { state: "running" as const, output: lines(1), delay: 100 }, + { state: "running" as const, output: lines(10), delay: 160 }, + { state: "running" as const, output: lines(25), delay: 90 }, + { state: "running" as const, output: lines(50), delay: 220 }, + { state: "completed" as const, output: lines(50), delay: 500 }, + ], + }, + { + name: "wide ANSI and CRLF output", + updates: [ + { + state: "running" as const, + output: Array.from({ length: 20 }, (_, index) => `\u001b[32mline ${index}\u001b[0m ${"wide-".repeat(30)}`).join( + "\r\n", + ), + delay: 240, + }, + { + state: "completed" as const, + output: Array.from({ length: 20 }, (_, index) => `line ${index} ${"wide-".repeat(30)}`).join("\n"), + delay: 500, + }, + ], + }, +] as const + +for (const profile of profiles) { + test(`keeps rows stable for shell ${profile.name}`, async ({ page }, testInfo) => { + const shellID = `prt_matrix_${profiles.indexOf(profile)}_01_shell` + const followingID = `prt_matrix_${profiles.indexOf(profile)}_02_following` + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([shell(shellID, "running"), textPart(followingID, "Following shell row")], { + completed: false, + }), + ], + settings: { shellToolPartsExpanded: true }, + cpuRate: 4, + seedHistory: true, + }) + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight)) + await waitForVisualSettle(page, [ + `[data-timeline-part-id="${shellID}"]`, + `[data-timeline-part-id="${followingID}"]`, + ]) + const regions = defineVisualRegions({ + shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + following: { + selector: `[data-timeline-part-id="${followingID}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, + }) + await startVisualProbe(page, regions) + for (const update of profile.updates) { + await timeline.send(partUpdated(shell(shellID, update.state, update.output)), update.delay) + } + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + `shell-${profiles.indexOf(profile)}`, + trace, + visualPlan( + regions, + [ + { type: "required", regions: ["shell", "following"] }, + { type: "unique", regions: ["shell", "following"] }, + { type: "stable", regions: ["shell", "following"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 }, + { type: "label-stability", regions: "all" }, + { type: "preserve-bottom-anchor" }, + { type: "flow", regions: ["shell", "following"] }, + ], + { perMarker: true }, + ), + ) + }) +} + +test("keeps following row stable when a collapsed shell receives 50 lines", async ({ page }, testInfo) => { + const shellID = "prt_matrix_collapsed_01_shell" + const followingID = "prt_matrix_collapsed_02_following" + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([shell(shellID, "running"), textPart(followingID, "Following collapsed shell")], { + completed: false, + }), + ], + settings: { shellToolPartsExpanded: false }, + cpuRate: 4, + seedHistory: true, + }) + await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`]) + const regions = defineVisualRegions({ + shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + }) + await startVisualProbe(page, regions) + await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 240) + await timeline.send(partUpdated(shell(shellID, "completed", lines(50))), 500) + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + "collapsed-shell", + trace, + visualPlan( + regions, + [ + { type: "required", regions: ["shell", "following"] }, + { type: "unique", regions: ["shell", "following"] }, + { type: "stable", regions: ["shell", "following"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: ["following"], maxPositionReversals: 0 }, + { type: "label-stability", regions: "all" }, + { type: "flow", regions: ["shell", "following"] }, + ], + { perMarker: true }, + ), + ) +}) + +test("keeps rows stable when a running shell becomes an error", async ({ page }, testInfo) => { + const shellID = "prt_matrix_error_01_shell" + const followingID = "prt_matrix_error_02_following" + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([shell(shellID, "running", lines(10)), textPart(followingID, "Following failed shell")], { + completed: false, + }), + ], + settings: { shellToolPartsExpanded: true }, + cpuRate: 4, + seedHistory: true, + }) + await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`]) + const regions = defineVisualRegions({ + shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + }) + await startVisualProbe(page, regions) + await timeline.send( + partUpdated({ + ...shell(shellID, "error"), + state: { + status: "error", + input: { command: `echo ${shellID}` }, + error: "Command failed after output", + metadata: {}, + time: { start: 1700000001000, end: 1700000002000 }, + }, + }), + 500, + ) + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + "shell-error", + trace, + visualPlan( + regions, + [ + { type: "required", regions: ["shell", "following"] }, + { type: "unique", regions: ["shell", "following"] }, + { type: "stable", regions: ["shell", "following"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: ["following"], maxPositionReversals: 0 }, + { type: "label-stability", regions: "all" }, + { type: "preserve-bottom-anchor" }, + { type: "flow", regions: ["shell", "following"] }, + ], + { perMarker: true }, + ), + ) +}) + +test("keeps rows stable when later text arrives before shell output", async ({ page }, testInfo) => { + const shellID = "prt_late_text_01_shell" + const followingID = "prt_late_text_02_following" + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage([shell(shellID, "running")], { completed: false })], + settings: { shellToolPartsExpanded: true }, + cpuRate: 4, + seedHistory: true, + }) + await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`]) + const regions = defineVisualRegions({ + shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + following: { + selector: `[data-timeline-part-id="${followingID}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, + }) + await startVisualProbe(page, regions) + await timeline.send(partUpdated(textPart(followingID, "Later assistant content arrived before shell output.")), 240) + await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 300) + await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 600) + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + "late-text-before-shell-output", + trace, + visualPlan( + regions, + [ + { type: "required", regions: ["shell", "following"] }, + { type: "unique", regions: ["shell", "following"] }, + { type: "stable", regions: ["shell"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxPositionReversals: 0 }, + { type: "label-stability", regions: "all" }, + { type: "preserve-bottom-anchor" }, + { type: "flow", regions: ["shell", "following"] }, + ], + { perMarker: true }, + ), + ) +}) + +function lines(count: number) { + return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n") +} diff --git a/packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts b/packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts new file mode 100644 index 000000000000..03690ec92147 --- /dev/null +++ b/packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts @@ -0,0 +1,106 @@ +import { expect, test } from "@playwright/test" +import { + defineVisualRegions, + reportVisualStability, + startVisualProbe, + stopVisualProbe, + visualPlan, +} from "../../utils/visual-stability" +import { + assistantMessage, + partUpdated, + session, + sessionID, + setupTimeline, + textPart, + toolPart, + userMessage, +} from "./fixture" + +test("adds a task child-session link without replacing the task row", async ({ page }, testInfo) => { + const taskID = "prt_task_link" + const childID = "ses_task_child" + const input = { description: "Inspect child", subagent_type: "explore" } + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage([toolPart(taskID, "task", "running", input)], { completed: false })], + sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect child" })], + cpuRate: 4, + }) + const regions = defineVisualRegions({ + task: { selector: `[data-timeline-part-id="${taskID}"] [data-slot="collapsible-trigger"]` }, + }) + await startVisualProbe(page, regions) + await timeline.send( + partUpdated(toolPart(taskID, "task", "completed", input, { metadata: { sessionId: childID } })), + 500, + ) + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + "task-link", + trace, + visualPlan(regions, [ + { type: "required", regions: ["task"] }, + { type: "unique", regions: ["task"] }, + { type: "stable", regions: ["task"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxPositionReversals: 0 }, + { type: "label-stability", regions: "all" }, + ]), + ) + await expect( + page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }), + ).toBeVisible() +}) + +test("changes generic tool arguments without replacing the row", async ({ page }, testInfo) => { + const toolID = "prt_generic_mutation" + const followingID = "prt_generic_mutation_following" + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage( + [ + toolPart(toolID, "mcp_probe", "running", { target: "one", count: 1 }), + textPart(followingID, "Following generic tool"), + ], + { completed: false }, + ), + ], + cpuRate: 4, + }) + const regions = defineVisualRegions({ + tool: { selector: `[data-timeline-part-id="${toolID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + }) + await startVisualProbe(page, regions) + await timeline.send( + partUpdated(toolPart(toolID, "mcp_probe", "running", { target: "two", count: 2, mode: "deep" })), + 200, + ) + await timeline.send( + partUpdated(toolPart(toolID, "mcp_probe", "completed", { target: "two", count: 2, mode: "deep" })), + 400, + ) + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + "generic-mutation", + trace, + visualPlan( + regions, + [ + { type: "required", regions: ["tool", "following"] }, + { type: "unique", regions: ["tool", "following"] }, + { type: "stable", regions: ["tool", "following"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxPositionReversals: 0 }, + { type: "label-stability", regions: "all" }, + { type: "flow", regions: ["tool", "following"] }, + ], + { perMarker: true }, + ), + ) +}) diff --git a/packages/app/e2e/performance/timeline-stability/tools.spec.ts b/packages/app/e2e/performance/timeline-stability/tools.spec.ts new file mode 100644 index 000000000000..d28fdaa65f58 --- /dev/null +++ b/packages/app/e2e/performance/timeline-stability/tools.spec.ts @@ -0,0 +1,198 @@ +import { expect, test } from "@playwright/test" +import { + defineVisualRegions, + reportVisualStability, + startVisualProbe, + stopVisualProbe, + visualPlan, +} from "../../utils/visual-stability" +import { + assistantMessage, + directory, + partUpdated, + session, + sessionID, + setupTimeline, + status, + textPart, + toolPart, + userMessage, +} from "./fixture" + +test.describe("timeline tool state stability", () => { + test("moves lightweight tools through pending, running, and completed without replacing rows", async ({ + page, + }, testInfo) => { + const ids = ["webfetch", "websearch", "task", "skill", "custom"] as const + const inputs = { + webfetch: { url: "https://example.com/docs" }, + websearch: { query: "timeline stability" }, + task: { description: "Inspect timeline", subagent_type: "explore" }, + skill: { name: "stability" }, + custom: { target: "timeline", depth: 2 }, + } + const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" } + const questionID = "prt_state_question" + const todoID = "prt_state_todo" + const initial = [ + ...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])), + toolPart(questionID, "question", "pending", questionInput()), + toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }), + textPart("prt_state_following", "Following lightweight tools"), + ] + const childID = "ses_timeline_child" + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage(initial, { completed: false })], + sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect timeline" })], + cpuRate: 4, + }) + await timeline.send(status("busy"), 120) + for (const id of ids) await timeline.waitForPart(`prt_state_${id}`) + await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0) + await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0) + + const regionIDs = [ + "prt_state_webfetch", + "prt_state_websearch", + "prt_state_task", + "prt_state_skill", + "prt_state_custom", + ] as const + const regions = defineVisualRegions({ + prt_state_webfetch: toolRegion(regionIDs[0]), + prt_state_websearch: toolRegion(regionIDs[1]), + prt_state_task: toolRegion(regionIDs[2]), + prt_state_skill: toolRegion(regionIDs[3]), + prt_state_custom: toolRegion(regionIDs[4]), + }) + await startVisualProbe(page, regions) + for (const [index, id] of ids.entries()) { + await timeline.send( + partUpdated(toolPart(`prt_state_${id}`, names[id], "running", inputs[id])), + [80, 240, 100, 360, 140][index], + ) + } + for (const [index, id] of ["skill", "webfetch", "custom", "task", "websearch"].entries()) { + const key = id as (typeof ids)[number] + const metadata = key === "task" ? { sessionId: childID } : key === "websearch" ? { provider: "exa" } : {} + const output = key === "websearch" ? "Result https://example.com/result" : "Completed" + await timeline.send( + partUpdated(toolPart(`prt_state_${key}`, names[key], "completed", inputs[key], { metadata, output })), + [110, 70, 280, 130, 420][index], + ) + } + await timeline.send( + partUpdated( + toolPart(questionID, "question", "completed", questionInput(), { metadata: { answers: [["Keep it stable"]] } }), + ), + 350, + ) + await timeline.waitForPart(questionID) + await timeline.send(status("idle"), 500) + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + "lightweight-tools", + trace, + visualPlan(regions, [ + { type: "required", regions: regionIDs }, + { type: "unique", regions: regionIDs }, + { type: "stable", regions: regionIDs }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxReversals: 4 }, + { type: "label-stability", regions: "all" }, + ]), + ) + await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable") + await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0) + await expect( + page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }), + ).toBeVisible() + await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible() + }) + + test("keeps an expanded mixed context group stable through staggered completion and error", async ({ + page, + }, testInfo) => { + const ids = ["prt_ctx_01_read", "prt_ctx_02_glob", "prt_ctx_03_grep", "prt_ctx_04_list"] + const tools = ["read", "glob", "grep", "list"] + const inputs = [ + { filePath: "src/a.ts", offset: 0, limit: 120 }, + { path: directory, pattern: "**/*.ts" }, + { path: directory, pattern: "stability", include: "*.ts" }, + { path: "src" }, + ] + const context = ids.map((id, index) => toolPart(id, tools[index]!, "pending", inputs[index]!)) + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([...context, textPart("prt_ctx_following", "Following context")], { completed: false }), + ], + cpuRate: 4, + }) + await timeline.send(status("busy"), 100) + const groupSelector = `[data-timeline-part-ids="${ids.join(",")}"]` + const group = page.locator(groupSelector) + await expect(group).toBeVisible() + await group.locator('[data-slot="collapsible-trigger"]').click() + await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true") + + const regions = defineVisualRegions({ + status: { + selector: `${groupSelector} [data-component="tool-status-title"]`, + opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'], + }, + context: { selector: groupSelector, closest: '[data-timeline-row="AssistantPart"]' }, + following: { + selector: '[data-timeline-part-id="prt_ctx_following"]', + closest: '[data-timeline-row="AssistantPart"]', + }, + }) + await startVisualProbe(page, regions) + for (const [index, delay] of [90, 260, 70, 380].entries()) { + await timeline.send(partUpdated(toolPart(ids[index]!, tools[index]!, "running", inputs[index]!)), delay) + } + await timeline.send(partUpdated(toolPart(ids[1]!, tools[1]!, "completed", inputs[1]!)), 130) + await timeline.send(partUpdated(toolPart(ids[3]!, tools[3]!, "completed", inputs[3]!)), 210) + await timeline.send( + partUpdated(toolPart(ids[0]!, tools[0]!, "error", inputs[0]!, { error: "Read interrupted" })), + 110, + ) + await timeline.send(partUpdated(toolPart(ids[2]!, tools[2]!, "completed", inputs[2]!)), 250) + await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored") + await timeline.send(status("idle"), 700) + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + "mixed-context", + trace, + visualPlan(regions, [ + { type: "required", regions: ["context", "following"] }, + { type: "unique", regions: ["context"] }, + { type: "stable", regions: ["context"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxReversals: 4 }, + { type: "label-stability", regions: "all" }, + { type: "flow", regions: ["context", "following"] }, + ]), + ) + await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored") + await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true") + await group.locator('[data-slot="collapsible-trigger"]').click() + await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false") + await timeline.send(partUpdated(textPart("prt_ctx_late_sibling", "Later sibling content")), 200) + await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false") + await group.locator('[data-slot="collapsible-trigger"]').click() + await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true") + }) +}) + +function questionInput() { + return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] } +} + +function toolRegion(id: string) { + return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' } +} diff --git a/packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts new file mode 100644 index 000000000000..e999e8c50a6f --- /dev/null +++ b/packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts @@ -0,0 +1,272 @@ +import { expect, test } from "@playwright/test" +import { + defineVisualRegions, + reportVisualStability, + startVisualProbe, + stopVisualProbe, + visualPlan, +} from "../../utils/visual-stability" +import { + assistantID, + assistantMessage, + completedAssistantInfo, + event, + messageUpdated, + partDelta, + partUpdated, + setupTimeline, + shell, + status, + textPart, + toolPart, + userMessage, +} from "./fixture" + +test("keeps unchanged siblings stable while a middle part is inserted and removed", async ({ page }, testInfo) => { + const firstID = "prt_mutation_01_first" + const middleID = "prt_mutation_02_middle" + const lastID = "prt_mutation_03_last" + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([textPart(firstID, "First stable row"), textPart(lastID, "Last stable row")], { + completed: false, + }), + ], + cpuRate: 4, + }) + const regions = defineVisualRegions({ + first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + }) + await startVisualProbe(page, regions) + await timeline.send(partUpdated(textPart(middleID, "Inserted middle row. ".repeat(12))), 350) + await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toBeVisible() + await timeline.send( + event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: middleID }), + 500, + ) + await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toHaveCount(0) + const trace = await stopVisualProbe(page) + await reportVisualStability(testInfo, "middle-insert-remove", trace, stablePairPlan(regions, 1)) +}) + +test("streams text through growth, canonical replacement, and completion", async ({ page }, testInfo) => { + const textID = "prt_text_reconcile" + const followingID = "prt_text_reconcile_following" + const assistant = assistantMessage([textPart(textID, "Starting"), textPart(followingID, "Following text row")], { + completed: false, + }) + const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 }) + const regions = defineVisualRegions({ + text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + }) + await startVisualProbe(page, regions) + await timeline.send(partDelta(textID, " streamed content"), 100) + await timeline.send(partDelta(textID, "\n\n- item one\n- item two\n- item three"), 180) + await timeline.send(partUpdated(textPart(textID, "Canonical replacement with a shorter final paragraph.")), 200) + await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 500) + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + "text-reconcile", + trace, + visualPlan(regions, [ + { type: "required", regions: ["text", "following"] }, + { type: "unique", regions: ["text", "following"] }, + { type: "stable", regions: ["text", "following"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxPositionReversals: 1, maxReversals: 2 }, + { type: "label-stability", regions: "all" }, + { type: "preserve-bottom-anchor" }, + { type: "flow", regions: ["text", "following"] }, + ]), + ) +}) + +test("inserts a completed question between stable rows", async ({ page }, testInfo) => { + const firstID = "prt_question_01_first" + const questionID = "prt_question_02_hidden" + const lastID = "prt_question_03_last" + const input = { questions: [{ header: "Choice", question: "Keep stable?", options: [] }] } + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage( + [ + textPart(firstID, "Before question"), + toolPart(questionID, "question", "running", input), + textPart(lastID, "After question"), + ], + { completed: false }, + ), + ], + cpuRate: 4, + }) + await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0) + const regions = defineVisualRegions({ + first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, + }) + await startVisualProbe(page, regions) + await timeline.send( + partUpdated(toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Yes"]] } })), + 600, + ) + await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toBeVisible() + const trace = await stopVisualProbe(page) + await reportVisualStability(testInfo, "question-insert", trace, stablePairPlan(regions, 0)) +}) + +test("replaces thinking with an assistant error without a blank turn", async ({ page }, testInfo) => { + const assistant = assistantMessage([], { completed: false }) + const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 }) + await timeline.send(status("busy"), 150) + await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() + const regions = defineVisualRegions({ + thinking: { selector: '[data-timeline-row="Thinking"]' }, + error: { selector: '[data-timeline-row="Error"]' }, + }) + await startVisualProbe(page, regions) + await timeline.send( + messageUpdated({ + ...assistant.info, + error: { name: "APIError", data: { message: "Provider failed visibly", isRetryable: false } }, + }), + 500, + ) + await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await expect(page.locator('[data-timeline-row="Error"]')).toContainText("Provider failed visibly") + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + "thinking-error", + trace, + visualPlan(regions, [ + { type: "required", regions: ["thinking", "error"] }, + { type: "continuous-any", regions: ["thinking", "error"] }, + { type: "unique", regions: ["thinking", "error"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all" }, + { type: "label-stability", regions: "all" }, + ]), + ) +}) + +test("updates retry attempts and long provider messages without remounting the retry row", async ({ + page, +}, testInfo) => { + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage([], { completed: false })], + cpuRate: 4, + }) + await timeline.send(status("retry", 1), 120) + await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible() + const regions = defineVisualRegions({ + retry: { selector: '[data-timeline-row="Retry"]' }, + }) + await startVisualProbe(page, regions) + await timeline.send( + event("session.status", { + sessionID: "ses_timeline_stability", + status: { + type: "retry", + attempt: 2, + message: "A very long provider retry message ".repeat(8), + next: Date.now() + 10_000, + }, + }), + 300, + ) + await timeline.send(status("retry", 3), 300) + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + "retry-evolution", + trace, + visualPlan( + regions, + [ + { type: "required", regions: ["retry"] }, + { type: "unique", regions: ["retry"] }, + { type: "stable", regions: ["retry"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxPositionReversals: 0 }, + { type: "label-stability", regions: "all" }, + ], + { perMarker: true }, + ), + ) +}) + +test("reducer-hardening: removes a historical turn one message at a time without moving a visible lower anchor twice", async ({ + page, +}, testInfo) => { + const removeUserID = "msg_0500_remove_user" + const removeAssistantID = "msg_0501_remove_assistant" + const anchorUserID = "msg_2000_anchor_user" + const timeline = await setupTimeline(page, { + messages: [ + userMessage(undefined, { id: removeUserID, created: 1690000000000 }), + assistantMessage([textPart("prt_remove_text", "Removed historical content. ".repeat(15))], { + id: removeAssistantID, + parentID: removeUserID, + created: 1690000001000, + }), + userMessage(undefined, { id: anchorUserID, created: 1700000000000 }), + assistantMessage([textPart("prt_anchor_text", "Visible anchor response")], { + id: "msg_2001_anchor_assistant", + parentID: anchorUserID, + created: 1700000001000, + }), + ], + cpuRate: 4, + }) + const regions = defineVisualRegions({ + anchor: { selector: `[data-timeline-row="UserMessage"][data-message-id="${anchorUserID}"]` }, + }) + await startVisualProbe(page, regions) + await timeline.send( + event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeAssistantID }), + 200, + ) + await timeline.send(event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeUserID }), 500) + const trace = await stopVisualProbe(page) + await reportVisualStability( + testInfo, + "historical-turn-remove", + trace, + visualPlan( + regions, + [ + { type: "required", regions: ["anchor"] }, + { type: "unique", regions: ["anchor"] }, + { type: "stable", regions: ["anchor"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxPositionReversals: 0 }, + { type: "label-stability", regions: "all" }, + ], + { perMarker: true }, + ), + ) +}) + +function stablePairPlan( + regions: Record<"first" | "last", { selector: string; closest?: string }>, + maxPositionReversals: number, +) { + return visualPlan(regions, [ + { type: "required", regions: ["first", "last"] }, + { type: "unique", regions: ["first", "last"] }, + { type: "stable", regions: ["first", "last"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all", maxPositionReversals }, + { type: "label-stability", regions: "all" }, + ]) +} diff --git a/packages/app/e2e/performance/timeline/first-navigation-benchmark.spec.ts b/packages/app/e2e/performance/timeline/first-navigation-benchmark.spec.ts new file mode 100644 index 000000000000..a07e8f3d89fb --- /dev/null +++ b/packages/app/e2e/performance/timeline/first-navigation-benchmark.spec.ts @@ -0,0 +1,87 @@ +import { expectSessionTitle } from "../../utils/waits" +import { benchmark, expect } from "../benchmark" +import { measureFirstNavigation } from "./first-navigation-probe" +import { fixture } from "./session-timeline-stress.fixture" +import { + installStressSessionTabs, + installTimelineSettings, + mockStressTimeline, + stressDraftHref, + stressSessionHref, +} from "./timeline-test-helpers" +import { waitForStableTimeline } from "./session-tab-switch-probe" + +const contentSelector = '[data-message-id], [data-component="prompt-input"]' +const draftID = "draft_first_navigation" + +benchmark.describe("performance: first navigation paint", () => { + benchmark("opens an unvisited session tab without a blank frame", async ({ page, report }) => { + await setup(page) + const href = stressSessionHref(fixture.targetID) + const result = await measureFirstNavigation(page, { + href, + destinationPath: href, + sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!), + destinationSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!), + contentSelector, + navigate: async () => { + await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click() + await expectSessionTitle(page, fixture.expected.targetTitle) + }, + }) + report(result) + expect(result.summary.blankSamples).toBe(0) + expect(result.summary.unknownSamples).toBe(0) + }) + + benchmark("opens the new session page before its lazy module is used", async ({ page, report }) => { + await setup(page, draftID) + const href = stressDraftHref(draftID) + const result = await measureFirstNavigation(page, { + href, + destinationPath: href, + sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!), + destinationSelector: '[data-component="prompt-input"]', + contentSelector, + navigate: async () => { + await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click() + await expect(page.locator('[data-component="prompt-input"]')).toBeVisible() + }, + }) + report(result) + expect(result.summary.blankSamples).toBe(0) + expect(result.summary.unknownSamples).toBe(0) + }) + + benchmark("opens a child session without a blank frame", async ({ page, report }) => { + await setup(page) + const href = stressSessionHref(fixture.childID) + const result = await measureFirstNavigation(page, { + href, + destinationPath: href, + sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!), + destinationSelector: messageSelector(fixture.expected.childMessageIDs.at(-1)!), + contentSelector, + navigate: async () => { + await page.locator(`a[href="${href}"]`, { has: page.locator('[data-component="task-tool-card"]') }).click() + await expectSessionTitle(page, fixture.expected.childTitle) + }, + }) + report(result) + expect(result.summary.blankSamples).toBe(0) + expect(result.summary.unknownSamples).toBe(0) + }) +}) + +async function setup(page: Parameters[0], draft?: string) { + await mockStressTimeline(page) + await installTimelineSettings(page) + await installStressSessionTabs(page, draft ? { draftID: draft } : undefined) + await page.goto(stressSessionHref(fixture.sourceID)) + await expectSessionTitle(page, fixture.expected.sourceTitle) + await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!) +} + +function messageSelector(id: string) { + return `[data-message-id="${id}"]` +} diff --git a/packages/app/e2e/performance/timeline/first-navigation-metrics.ts b/packages/app/e2e/performance/timeline/first-navigation-metrics.ts new file mode 100644 index 000000000000..db3867804691 --- /dev/null +++ b/packages/app/e2e/performance/timeline/first-navigation-metrics.ts @@ -0,0 +1,32 @@ +export type FirstNavigationSample = { + observedAtMs: number + source: boolean + destination: boolean + content: boolean + pathname?: string + center?: string +} + +function category(sample: FirstNavigationSample) { + if (sample.destination && !sample.source) return "destination" + if (sample.source && !sample.destination) return "source" + if (!sample.content) return "blank" + return "unknown" +} + +export function summarizeFirstNavigation(samples: FirstNavigationSample[]) { + const categories = samples.map(category) + const stable = categories.findIndex( + (value, index) => + value === "destination" && categories[index + 1] === "destination" && categories[index + 2] === "destination", + ) + return { + samples: samples.length, + firstDestinationObservedMs: samples[categories.indexOf("destination")]?.observedAtMs ?? null, + stableDestinationObservedMs: stable === -1 ? null : samples[stable + 2]!.observedAtMs, + sourceSamples: categories.filter((value) => value === "source").length, + blankSamples: categories.filter((value) => value === "blank").length, + unknownSamples: categories.filter((value) => value === "unknown").length, + destinationSamples: categories.filter((value) => value === "destination").length, + } +} diff --git a/packages/app/e2e/performance/timeline/first-navigation-probe.ts b/packages/app/e2e/performance/timeline/first-navigation-probe.ts new file mode 100644 index 000000000000..0e7ef45f2ffb --- /dev/null +++ b/packages/app/e2e/performance/timeline/first-navigation-probe.ts @@ -0,0 +1,86 @@ +import type { Page } from "@playwright/test" +import { summarizeFirstNavigation, type FirstNavigationSample } from "./first-navigation-metrics" + +type FirstNavigationProbe = { + samples: FirstNavigationSample[] + stop: () => void +} + +export async function measureFirstNavigation( + page: Page, + input: { + href: string + destinationPath: string + sourceSelector: string + destinationSelector: string + contentSelector: string + navigate: () => Promise + }, +) { + await page.evaluate( + ({ href, destinationPath, sourceSelector, destinationSelector, contentSelector }) => { + const samples: FirstNavigationSample[] = [] + let started: number | undefined + let running = true + const visible = (selector: string) => + [...document.querySelectorAll(selector)].some((element) => { + const rect = element.getBoundingClientRect() + const style = getComputedStyle(element) + return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none" + }) + const sample = () => { + if (!running || started === undefined) return + requestAnimationFrame(() => { + setTimeout(() => { + if (!running || started === undefined) return + samples.push({ + observedAtMs: performance.now() - started, + source: visible(sourceSelector), + destination: `${location.pathname}${location.search}` === destinationPath && visible(destinationSelector), + content: visible(contentSelector), + pathname: `${location.pathname}${location.search}`, + center: document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.textContent?.slice(0, 80), + }) + sample() + }, 0) + }) + } + document.addEventListener( + "click", + (event) => { + const link = event.target instanceof Element ? event.target.closest("a") : undefined + if (link?.getAttribute("href") !== href) return + started = performance.now() + sample() + }, + { capture: true, once: true }, + ) + ;(window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe = { + samples, + stop: () => { + running = false + }, + } + }, + { + href: input.href, + destinationPath: input.destinationPath, + sourceSelector: input.sourceSelector, + destinationSelector: input.destinationSelector, + contentSelector: input.contentSelector, + }, + ) + await input.navigate() + await page.waitForFunction(() => { + const samples = (window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe + ?.samples + if (!samples) return false + return samples.length >= 3 && samples.slice(-3).every((sample) => sample.destination && !sample.source) + }) + const samples = await page.evaluate(() => { + const probe = (window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe! + probe.stop() + return probe.samples + }) + return { summary: summarizeFirstNavigation(samples), samples } +} diff --git a/packages/app/e2e/performance/timeline/home-tab-navigation-benchmark.spec.ts b/packages/app/e2e/performance/timeline/home-tab-navigation-benchmark.spec.ts new file mode 100644 index 000000000000..9b74c7d6bfd4 --- /dev/null +++ b/packages/app/e2e/performance/timeline/home-tab-navigation-benchmark.spec.ts @@ -0,0 +1,114 @@ +import { benchmark, expect } from "../benchmark" +import { expectSessionTitle } from "../../utils/waits" +import { measureNavigationMilestones } from "./navigation-milestones" +import { fixture } from "./session-timeline-stress.fixture" +import { + installStressSessionTabs, + installTimelineSettings, + mockStressTimeline, + stressSessionHref, +} from "./timeline-test-helpers" +import { waitForStableTimeline } from "./session-tab-switch-probe" + +const homeRow = '[data-component="home-session-row"]' +const homeShell = '[data-component="home-session-search"]' + +benchmark.describe("performance: home and tab navigation", () => { + benchmark("opens a home session and paints its titlebar tab", async ({ page, report }) => { + await setup(page, []) + await page.goto("/") + const row = page.locator(homeRow).filter({ hasText: fixture.expected.targetTitle }).first() + await expect(row).toBeVisible() + const href = stressSessionHref(fixture.targetID) + const result = await measureNavigationMilestones(page, { + triggerSelector: homeRow, + milestones: { + content: { selector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!) }, + tab: { selector: `[data-slot="titlebar-tabs"] a[href="${href}"]` }, + }, + navigate: async () => { + await row.click() + await expectSessionTitle(page, fixture.expected.targetTitle) + }, + }) + report(result) + await expect(page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`)).toContainText( + fixture.expected.targetTitle, + ) + }) + + benchmark("stages the review body after cold session content", async ({ page, report }) => { + await setup(page, []) + await page.goto("/") + const row = page.locator(homeRow).filter({ hasText: fixture.expected.targetTitle }).first() + await expect(row).toBeVisible() + const result = await page.evaluate( + ({ rowSelector, title, contentSelector }) => + new Promise<{ contentBeforeReview: boolean; samples: number }>((resolve) => { + let samples = 0 + const sample = () => { + samples++ + const content = !!document.querySelector(contentSelector) + const review = !!document.querySelector('[data-component="session-review"]') + if (content && !review) { + resolve({ contentBeforeReview: true, samples }) + return + } + if (content && review) { + resolve({ contentBeforeReview: false, samples }) + return + } + requestAnimationFrame(sample) + } + const target = [...document.querySelectorAll(rowSelector)].find((item) => + item.textContent?.includes(title), + ) + if (!target) throw new Error(`Home session row not found: ${title}`) + target.click() + requestAnimationFrame(sample) + }), + { + rowSelector: homeRow, + title: fixture.expected.targetTitle, + contentSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!), + }, + ) + report(result) + expect(result.contentBeforeReview).toBe(true) + await expect(page.locator('[data-component="session-review"]')).toBeVisible() + }) + + benchmark("closes the only session tab and paints home", async ({ page, report }) => { + await setup(page, [fixture.sourceID]) + const href = stressSessionHref(fixture.sourceID) + await page.goto(href) + await expectSessionTitle(page, fixture.expected.sourceTitle) + await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!) + const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first() + const close = tab.locator("..").locator('[data-component="icon-button-v2"]') + await expect(close).toBeVisible() + const result = await measureNavigationMilestones(page, { + triggerSelector: '[data-slot="titlebar-tabs"] [data-component="icon-button-v2"]', + milestones: { + home: { selector: homeShell }, + row: { selector: homeRow }, + tabRemoved: { selector: `[data-slot="titlebar-tabs"] a[href="${href}"]`, visible: false }, + }, + navigate: async () => { + await close.click() + await expect(page).toHaveURL("/") + }, + }) + report(result) + }) +}) + +async function setup(page: Parameters[0], sessionIDs: string[]) { + await mockStressTimeline(page) + await installTimelineSettings(page) + await installStressSessionTabs(page, { sessionIDs }) +} + +function messageSelector(id: string) { + return `[data-message-id="${id}"]` +} diff --git a/packages/app/e2e/performance/timeline/navigation-milestones.ts b/packages/app/e2e/performance/timeline/navigation-milestones.ts new file mode 100644 index 000000000000..b8ec858e84a3 --- /dev/null +++ b/packages/app/e2e/performance/timeline/navigation-milestones.ts @@ -0,0 +1,128 @@ +import type { Page } from "@playwright/test" + +export type NavigationMilestoneSample = { + observedAtMs: number + milestones: Record +} + +export function summarizeNavigationMilestones(samples: NavigationMilestoneSample[]) { + const names = Object.keys(samples[0]?.milestones ?? {}) + const summarize = (matches: (sample: NavigationMilestoneSample) => boolean) => { + const first = samples.find(matches) + const stable = samples.findIndex( + (sample, index) => + index + 2 < samples.length && matches(sample) && matches(samples[index + 1]!) && matches(samples[index + 2]!), + ) + return { + firstObservedMs: first?.observedAtMs ?? null, + stableObservedMs: stable === -1 ? null : samples[stable + 2]!.observedAtMs, + } + } + return { + samples: samples.length, + milestones: Object.fromEntries( + names.map((name) => [name, summarize((sample) => sample.milestones[name] === true)]), + ), + all: summarize((sample) => names.every((name) => sample.milestones[name] === true)), + } +} + +type NavigationMilestoneProbe = { + samples: NavigationMilestoneSample[] + stop: () => void +} + +export async function measureNavigationMilestones( + page: Page, + input: { + triggerSelector: string + milestones: Record + navigate: () => Promise + }, +) { + await page.evaluate( + ({ triggerSelector, milestones }) => { + const samples: NavigationMilestoneSample[] = [] + const streaks = new Map() + const marked = new Set() + let started: number | undefined + let running = true + const visible = (selector: string) => + [...document.querySelectorAll(selector)].some((element) => { + const rect = element.getBoundingClientRect() + const style = getComputedStyle(element) + return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none" + }) + const sample = () => { + if (!running || started === undefined) return + requestAnimationFrame(() => { + setTimeout(() => { + if (!running || started === undefined) return + const current = Object.fromEntries( + Object.entries(milestones).map(([name, milestone]) => [ + name, + milestone.visible === false ? !document.querySelector(milestone.selector) : visible(milestone.selector), + ]), + ) + samples.push({ + observedAtMs: performance.now() - started, + milestones: current, + }) + Object.entries(current).forEach(([name, value]) => { + if (!value) { + streaks.set(name, 0) + return + } + if (!marked.has(`${name}.first`)) { + performance.mark(`opencode.navigation.${name}.first`) + marked.add(`${name}.first`) + } + const streak = (streaks.get(name) ?? 0) + 1 + streaks.set(name, streak) + if (streak === 3) performance.mark(`opencode.navigation.${name}.stable`) + }) + const all = Object.values(current).every(Boolean) + const allStreak = all ? (streaks.get("all") ?? 0) + 1 : 0 + streaks.set("all", allStreak) + if (all && !marked.has("all.first")) { + performance.mark("opencode.navigation.all.first") + marked.add("all.first") + } + if (allStreak === 3) performance.mark("opencode.navigation.all.stable") + sample() + }, 0) + }) + } + document.addEventListener( + "click", + (event) => { + if (!(event.target instanceof Element) || !event.target.closest(triggerSelector)) return + started = performance.now() + performance.mark("opencode.navigation.click") + sample() + }, + { capture: true, once: true }, + ) + ;(window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones = { + samples, + stop: () => { + running = false + }, + } + }, + { triggerSelector: input.triggerSelector, milestones: input.milestones }, + ) + await input.navigate() + await page.waitForFunction(() => { + const samples = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones + ?.samples + if (!samples || samples.length < 3) return false + return samples.slice(-3).every((sample) => Object.values(sample.milestones).every(Boolean)) + }) + const samples = await page.evaluate(() => { + const probe = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones! + probe.stop() + return probe.samples + }) + return { summary: summarizeNavigationMilestones(samples), samples } +} diff --git a/packages/app/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts b/packages/app/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts new file mode 100644 index 000000000000..81de0a055f90 --- /dev/null +++ b/packages/app/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts @@ -0,0 +1,312 @@ +import type { Page } from "@playwright/test" +import { benchmark, expect } from "../benchmark" +import { setupTimelineBenchmark } from "./session-timeline-benchmark.fixture" + +const changedLinesPerFile = 100 +const linesPerSide = changedLinesPerFile / 2 +const fileCounts = [1, 10, 100, 1_000, 10_000] +const filesPerDirectory = 100 +const readyFrames = 3 +const completionTimeoutMs = Number(process.env.REVIEW_PANE_COMPLETION_TIMEOUT_MS ?? 900_000) + +type ReviewPaneScalingSample = { + observedAtMs: number + logicalRows: number + treeRows: number + fileRows: number + diffLines: number + header: string + ready: boolean +} + +type ReviewPaneScalingProbe = { + startedAt?: number + firstTreeRowMs?: number + logicalTreeReadyMs?: number + firstDiffRenderMs?: number + stableReadyMs?: number + samples: ReviewPaneScalingSample[] + frameTimesMs: number[] + longTasks: { startTime: number; duration: number }[] + stop: () => void +} + +benchmark.describe("performance: review pane scaling", () => { + for (const fileCount of fileCounts) { + const changedLines = fileCount * changedLinesPerFile + + benchmark( + `${changedLines} changed lines across ${fileCount} ${fileCount === 1 ? "file" : "files"}`, + async ({ page, report }) => { + benchmark.setTimeout(1_200_000) + await page.emulateMedia({ reducedMotion: "reduce" }) + + const patchByteLimit = Number(process.env.REVIEW_PANE_PATCH_BYTE_LIMIT ?? Number.POSITIVE_INFINITY) + if (Number.isNaN(patchByteLimit) || patchByteLimit < 0) + throw new Error(`Invalid REVIEW_PANE_PATCH_BYTE_LIMIT: ${process.env.REVIEW_PANE_PATCH_BYTE_LIMIT}`) + const responseBody = JSON.stringify(createScalingDiffs(fileCount, patchByteLimit)) + await setupTimelineBenchmark(page, { + historyTurns: 0, + eventBatch: 1, + newLayoutDesigns: true, + }) + await page.route("**/vcs/diff**", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: responseBody, + }), + ) + + const expectedRows = fileCount + 2 + Math.ceil(fileCount / filesPerDirectory) + const metrics = await measureReviewPaneLoad(page, { + expectedFile: reviewFile(0), + expectedRows, + }) + const search = await measureBroadReviewSearch(page, fileCount) + + expect(metrics.logicalRows).toBe(expectedRows) + expect(metrics.fileRows).toBeGreaterThan(0) + expect(metrics.treeRows).toBeGreaterThan(0) + expect(metrics.diffLines).toBeGreaterThan(0) + expect(search.logicalRows).toBe(fileCount) + expect(search.renderedRows).toBeGreaterThan(0) + report( + { ...metrics, search }, + { + fileCount, + changedLinesPerFile, + changedLines, + additions: changedLines / 2, + deletions: changedLines / 2, + patchLines: changedLines, + patchByteLimit: Number.isFinite(patchByteLimit) ? patchByteLimit : null, + payloadBytes: new TextEncoder().encode(responseBody).byteLength, + expectedRows, + }, + ) + }, + ) + } +}) + +async function measureBroadReviewSearch(page: Page, expectedRows: number) { + const filter = page.getByRole("searchbox", { name: "Filter files" }) + await filter.evaluate((element) => { + element.addEventListener( + "input", + () => { + ;(window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt = performance.now() + }, + { once: true, capture: true }, + ) + }) + await filter.fill("file-") + + return page.evaluate((expectedRows) => { + const startedAt = (window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt! + return new Promise<{ stableMs: number; logicalRows: number; renderedRows: number }>((resolve) => { + let previous = -1 + let streak = 0 + const sample = () => { + const tree = document.querySelector('#review-panel [data-component="file-tree-v2"]') + const rows = [...document.querySelectorAll('#review-panel [data-slot="file-tree-v2-row"]')] + const logicalRows = Number(tree?.dataset.totalRows ?? rows.length) + const ready = + logicalRows === expectedRows && rows.length > 0 && rows.every((row) => row.textContent?.includes("file-")) + streak = ready && rows.length === previous ? streak + 1 : ready ? 1 : 0 + previous = rows.length + if (streak >= 3) { + resolve({ stableMs: performance.now() - startedAt, logicalRows, renderedRows: rows.length }) + return + } + requestAnimationFrame(sample) + } + requestAnimationFrame(sample) + }) + }, expectedRows) +} + +function createScalingDiffs(fileCount: number, patchByteLimit: number) { + const changes = Array.from({ length: linesPerSide }, (_, index) => { + const line = String(index).padStart(3, "0") + return `-export const value_${line} = "before"\n+export const value_${line} = "after"` + }).join("\n") + let patchBytes = 0 + let capped = false + + return Array.from({ length: fileCount }, (_, index) => { + const file = reviewFile(index) + const fullPatch = [ + `diff --git a/${file} b/${file}`, + `--- a/${file}`, + `+++ b/${file}`, + `@@ -1,${linesPerSide} +1,${linesPerSide} @@`, + changes, + ].join("\n") + if (index === 0 && fullPatch.length > patchByteLimit) + throw new Error(`REVIEW_PANE_PATCH_BYTE_LIMIT must include the active patch (${fullPatch.length} bytes)`) + const patch = !capped && patchBytes + fullPatch.length <= patchByteLimit ? fullPatch : emptyReviewPatch(file) + if (patch === fullPatch) patchBytes += fullPatch.length + else capped = true + return { + file, + patch, + additions: linesPerSide, + deletions: linesPerSide, + status: "modified" as const, + } + }) +} + +function emptyReviewPatch(file: string) { + return [`diff --git a/${file} b/${file}`, `--- a/${file}`, `+++ b/${file}`].join("\n") +} + +function reviewFile(index: number) { + return `src/review/d${String(Math.floor(index / filesPerDirectory)).padStart(5, "0")}/file-${String(index).padStart(5, "0")}.ts` +} + +async function measureReviewPaneLoad(page: Page, input: { expectedFile: string; expectedRows: number }) { + const toggle = page.getByRole("button", { name: "Toggle review" }) + await expect(toggle).toBeVisible() + await toggle.evaluate((element) => element.setAttribute("data-review-pane-scaling-toggle", "")) + await installReviewPaneScalingProbe(page, input) + await toggle.click() + await page.waitForFunction( + () => + (window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe + ?.stableReadyMs !== undefined, + undefined, + { timeout: completionTimeoutMs }, + ) + + return page.evaluate(() => { + const probe = (window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe! + probe.stop() + const startedAt = probe.startedAt! + const final = probe.samples.at(-1)! + const resources = performance + .getEntriesByType("resource") + .filter((entry) => entry.name.includes("/vcs/diff")) as PerformanceResourceTiming[] + const resource = resources.at(-1) + const longTasks = probe.longTasks.filter( + (entry) => entry.startTime >= startedAt && entry.startTime <= startedAt + probe.stableReadyMs!, + ) + const frameGaps = probe.frameTimesMs.map((time, index) => time - (probe.frameTimesMs[index - 1] ?? 0)) + + return { + firstTreeRowMs: probe.firstTreeRowMs ?? null, + logicalTreeReadyMs: probe.logicalTreeReadyMs ?? null, + firstDiffRenderMs: probe.firstDiffRenderMs ?? null, + stableReadyMs: probe.stableReadyMs ?? null, + responseStartMs: resource ? resource.responseStart - startedAt : null, + responseEndMs: resource ? resource.responseEnd - startedAt : null, + responseToStableMs: resource ? probe.stableReadyMs! - (resource.responseEnd - startedAt) : null, + treeRows: final.treeRows, + logicalRows: final.logicalRows, + fileRows: final.fileRows, + diffLines: final.diffLines, + samples: probe.samples.length, + maxFrameGapMs: Math.max(0, ...frameGaps), + longTaskCount: longTasks.length, + longTaskTotalMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0), + maxLongTaskMs: Math.max(0, ...longTasks.map((entry) => entry.duration)), + } + }) +} + +async function installReviewPaneScalingProbe(page: Page, input: { expectedFile: string; expectedRows: number }) { + await page.evaluate( + ({ expectedFile, expectedRows, stableFrames }) => { + let running = true + let readyStreak = 0 + const basename = expectedFile.split("/").at(-1)! + const longTaskObserver = PerformanceObserver.supportedEntryTypes.includes("longtask") + ? new PerformanceObserver((list) => { + probe.longTasks.push( + ...list.getEntries().map((entry) => ({ startTime: entry.startTime, duration: entry.duration })), + ) + }) + : undefined + const probe: ReviewPaneScalingProbe = { + samples: [], + frameTimesMs: [], + longTasks: [], + stop: () => { + running = false + longTaskObserver?.disconnect() + }, + } + + const sample = (time: number) => { + if (!running || probe.startedAt === undefined) return + const panel = document.querySelector("#review-panel") + const tree = panel?.querySelector('[data-component="file-tree-v2"]') + const rows = panel?.querySelectorAll('[data-slot="file-tree-v2-row"]') ?? [] + const fileRows = panel?.querySelectorAll('button[data-slot="file-tree-v2-row"]') ?? [] + const header = + panel?.querySelector('[data-slot="session-review-v2-file-header"]')?.textContent?.trim() ?? "" + const viewers = panel + ? [...panel.querySelectorAll('[data-component="file"][data-mode="diff"]')] + : [] + const diffLines = viewers.reduce( + (sum, viewer) => + sum + (viewer.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0), + 0, + ) + const observedAtMs = time - probe.startedAt + const logicalRows = Number(tree?.dataset.totalRows ?? rows.length) + const ready = + logicalRows === expectedRows && + fileRows.length > 0 && + header.includes(basename) && + viewers.length === 1 && + diffLines > 0 + const previous = probe.samples.at(-1) + const stable = + ready && + previous?.ready === true && + previous.logicalRows === logicalRows && + previous.treeRows === rows.length && + previous.fileRows === fileRows.length && + previous.diffLines === diffLines && + previous.header === header + + probe.frameTimesMs.push(observedAtMs) + probe.samples.push({ + observedAtMs, + logicalRows, + treeRows: rows.length, + fileRows: fileRows.length, + diffLines, + header, + ready, + }) + if (probe.firstTreeRowMs === undefined && rows.length > 0) probe.firstTreeRowMs = observedAtMs + if (probe.logicalTreeReadyMs === undefined && logicalRows === expectedRows) + probe.logicalTreeReadyMs = observedAtMs + if (probe.firstDiffRenderMs === undefined && diffLines > 0) probe.firstDiffRenderMs = observedAtMs + readyStreak = !ready ? 0 : stable ? readyStreak + 1 : 1 + if (readyStreak === stableFrames) probe.stableReadyMs = observedAtMs + if (probe.stableReadyMs === undefined) requestAnimationFrame(sample) + } + + longTaskObserver?.observe({ type: "longtask", buffered: true }) + document.addEventListener( + "click", + (event) => { + const toggle = event.target instanceof Element ? event.target.closest("button") : undefined + if (!toggle?.hasAttribute("data-review-pane-scaling-toggle")) return + probe.startedAt = performance.now() + performance.mark("opencode.review-pane-scaling.click") + requestAnimationFrame(sample) + }, + { capture: true, once: true }, + ) + ;(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe = probe + }, + { ...input, stableFrames: readyFrames }, + ) +} diff --git a/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts b/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts new file mode 100644 index 000000000000..838af17c9399 --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts @@ -0,0 +1,172 @@ +import type { Page } from "@playwright/test" +import { expectSessionTitle } from "../../utils/waits" +import { mockOpenCodeServer } from "../../utils/mock-server" +import { benchmark, expect, withBenchmarkPage } from "../benchmark" +import { fixture } from "./session-timeline-stress.fixture" +import { installStressSessionTabs, stressSessionHref } from "./timeline-test-helpers" +import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe" + +type ParentHydrationBenchmarkMode = "natural" | "candidate" + +const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural" +if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`) +const userID = "msg_parent_hydration_user" +const user = { + ...fixture.messages[fixture.targetID][0]!, + info: { ...fixture.messages[fixture.targetID][0]!.info, id: userID, time: { created: 1700001000000 } }, + parts: fixture.messages[fixture.targetID][0]!.parts.map((part, index) => ({ + ...part, + id: `prt_parent_hydration_user_${index}`, + messageID: userID, + })), +} +const assistantSeed = fixture.messages[fixture.targetID][3]! +const assistants = Array.from({ length: 14 }, (_, index) => { + const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}` + return { + ...assistantSeed, + info: { + ...assistantSeed.info, + id: messageID, + parentID: userID, + time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 }, + }, + parts: assistantSeed.parts.map((part, partIndex) => ({ + ...part, + id: `prt_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}`, + messageID, + })), + } +}) +const messages = [user, ...assistants] +const target = fixture.sessions.find((session) => session.id === fixture.targetID)! +const lastID = userID +const lastAssistant = assistants.at(-1)! +const lastPart = lastAssistant.parts.at(-1)! +const lastPartID = + lastPart.type === "tool" + ? lastPart.id + : `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}` + +benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => { + benchmark.setTimeout(180_000) + const results = [] as Awaited>[] + for (let run = 0; run < 5; run++) { + results.push( + await withBenchmarkPage( + browser, + `session-parent-hydration-${mode}-${run}`, + (page) => trial(page, mode), + testInfo, + ), + ) + } + const timing = results.map((result) => result.metrics.firstCorrectObservedMs!).sort((a, b) => a - b) + report( + { + results: results.map((result) => ({ ...result.metrics, historyGateCount: result.historyGateCount })), + summary: { + firstCorrectObservedMs: { min: timing[0], median: timing[2], max: timing.at(-1) }, + blankSamples: results.map((result) => result.metrics.blankSamples), + requestCounts: { + list: results.map((result) => result.requestCounts.list), + parent: results.map((result) => result.requestCounts.parent), + }, + historyGateCount: results.map((result) => result.historyGateCount), + }, + }, + { mode }, + ) +}) + +async function trial(page: Page, mode: ParentHydrationBenchmarkMode) { + const requests: { type: "list" | "parent"; before?: string }[] = [] + const history = mode === "candidate" ? Promise.withResolvers() : undefined + let historyGates = 0 + await mockOpenCodeServer(page, { + sessions: fixture.sessions.filter((session) => session.id === fixture.sourceID), + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + messageDelay: 50, + onMessages: (request) => { + if (request.sessionID === fixture.targetID && request.phase === "start") + requests.push({ type: "list", before: request.before }) + }, + beforeMessagesResponse: (request) => { + if (mode !== "candidate" || request.sessionID !== fixture.targetID || !request.before) return Promise.resolve() + historyGates++ + return history!.promise + }, + onMessage: (request) => { + if (request.sessionID === fixture.targetID && request.messageID === userID) requests.push({ type: "parent" }) + }, + message: (sessionID, messageID) => { + if (sessionID !== fixture.targetID || messageID !== userID) return + return user + }, + pageMessages: (sessionID, limit, before) => { + const items = sessionID === fixture.targetID ? messages : fixture.messages[fixture.sourceID] + const end = before ? items.findIndex((message) => message.info.id === before) : items.length + const start = Math.max(0, end - limit) + return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined } + }, + }) + await page.route(`**/session/${fixture.targetID}`, (route) => { + const current = new URL(route.request().url()).pathname.startsWith("/api/") + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + current + ? { + data: { + ...target, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + location: { directory: target.directory }, + }, + } + : target, + ), + }) + }) + await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] }) + await page.goto(stressSessionHref(fixture.sourceID)) + await expectSessionTitle(page, fixture.expected.sourceTitle) + await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!) + + const href = stressSessionHref(fixture.targetID) + await page.evaluate( + ({ href, title }) => { + const link = document.createElement("a") + link.id = "parent-hydration-target" + link.href = href + link.textContent = title + document.body.append(link) + }, + { href, title: target.title }, + ) + const metrics = await measureSessionSwitch(page, { + destinationIDs: messages.map((message) => message.info.id), + sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.info.id), + lastID, + requiredPartID: lastPartID, + requireBottomAnchor: false, + href, + switch: async () => { + await page.locator("#parent-hydration-target").click() + await expectSessionTitle(page, target.title) + }, + }).finally(() => history?.resolve()) + expect(metrics.firstCorrectObservedMs).not.toBeNull() + const requestCounts = { + list: requests.filter((request) => request.type === "list").length, + parent: requests.filter((request) => request.type === "parent").length, + } + if (mode === "candidate") { + expect(requestCounts.parent).toBe(0) + expect(historyGates).toBe(0) + } + return { metrics, requestCounts, historyGateCount: historyGates } +} diff --git a/packages/app/e2e/performance/timeline/session-tab-flash.spec.ts b/packages/app/e2e/performance/timeline/session-tab-flash.spec.ts new file mode 100644 index 000000000000..051b29d0cd9f --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-tab-flash.spec.ts @@ -0,0 +1,67 @@ +import { benchmark, expect } from "../benchmark" +import { expectSessionTitle } from "../../utils/waits" +import { fixture } from "./session-timeline-stress.fixture" +import { + collectCachedRepaintTrace, + compressCachedRepaintTrace, + installCachedRepaintProbe, + waitForCachedRepaintWindow, +} from "./session-tab-repaint-probe" +import { waitForStableTimeline } from "./session-tab-switch-probe" +import { + installStressSessionTabs, + installTimelineSettings, + mockStressTimeline, + stressSessionHref, +} from "./timeline-test-helpers" + +benchmark("samples cached session repaint after the click", async ({ page, report }) => { + benchmark.setTimeout(120_000) + await mockStressTimeline(page) + await installStressSessionTabs(page) + await installTimelineSettings(page) + await page.goto(stressSessionHref(fixture.targetID)) + await expectSessionTitle(page, fixture.expected.targetTitle) + await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!) + await page + .locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.sourceID)}"]`) + .first() + .click() + await expectSessionTitle(page, fixture.expected.sourceTitle) + await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!) + + await installCachedRepaintProbe(page, { + targetHref: stressSessionHref(fixture.targetID), + destination: fixture.messages[fixture.targetID].map((message) => message.info.id), + source: fixture.messages[fixture.sourceID].map((message) => message.info.id), + last: fixture.expected.targetMessageIDs.at(-1)!, + windowMs: 1_000, + }) + + await page + .locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.targetID)}"]`) + .first() + .click() + await Promise.all([expectSessionTitle(page, fixture.expected.targetTitle), waitForCachedRepaintWindow(page, 1_000)]) + const result = await collectCachedRepaintTrace(page) + report(compressCachedRepaintTrace(result)) + expect(result.samples.length).toBeGreaterThan(0) +}) + +benchmark("prefetches every open session tab", async ({ page, report }) => { + const prefetched = new Set() + await mockStressTimeline(page, { + onMessages: (input) => { + if (!input.before && input.phase === "start") prefetched.add(input.sessionID) + }, + }) + await installStressSessionTabs(page, { + sessionIDs: [fixture.sourceID, fixture.targetID, fixture.childID], + }) + await installTimelineSettings(page) + await page.goto(stressSessionHref(fixture.sourceID)) + await expectSessionTitle(page, fixture.expected.sourceTitle) + + await expect.poll(() => prefetched.has(fixture.childID)).toBe(true) + report({ prefetched: [...prefetched] }) +}) diff --git a/packages/app/e2e/performance/timeline/session-tab-repaint-probe.ts b/packages/app/e2e/performance/timeline/session-tab-repaint-probe.ts new file mode 100644 index 000000000000..862e080f13dc --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-tab-repaint-probe.ts @@ -0,0 +1,251 @@ +import type { Page } from "@playwright/test" + +type CachedRepaintTrace = { + timeOriginEpochMs: number + startedAtPerformanceMs: number + samples: { + observedAtMs: number + root: number | undefined + scrollTop: number + scrollHeight: number + bottomErrorPx: number | undefined + last: boolean + rows: { key: string | undefined; node: number; top: number; bottom: number }[] + mounted: number + center: string | undefined + destination: string[] + source: string[] + }[] + mutations: { observedAtMs: number; changed: { type: string; node: number }[] }[] + shifts: { occurredAtMs: number; value: number }[] + windowMs: number + running: boolean + stop: () => void +} + +export async function installCachedRepaintProbe( + page: Page, + input: { targetHref: string; destination: string[]; source: string[]; last: string; windowMs: number }, +) { + await page.evaluate(({ targetHref, destination, source, last, windowMs }) => { + const destinationIDs = new Set(destination) + const sourceIDs = new Set(source) + const nodeIDs = new WeakMap() + let nextNodeID = 1 + const id = (node: Node) => { + const current = nodeIDs.get(node) + if (current) return current + nodeIDs.set(node, nextNodeID) + return nextNodeID++ + } + const state: CachedRepaintTrace = { + timeOriginEpochMs: performance.timeOrigin, + startedAtPerformanceMs: 0, + samples: [], + mutations: [], + shifts: [], + windowMs, + running: false, + stop: () => {}, + } + const recordShifts = (entries: PerformanceEntry[]) => { + if (!state.running) return + state.shifts.push( + ...entries + .map((entry) => { + if ( + entry.startTime < state.startedAtPerformanceMs || + entry.startTime > state.startedAtPerformanceMs + state.windowMs + ) + return + return { + occurredAtMs: entry.startTime - state.startedAtPerformanceMs, + value: (entry as PerformanceEntry & { value: number }).value, + } + }) + .filter((entry): entry is { occurredAtMs: number; value: number } => entry !== undefined), + ) + } + const shiftObserver = new PerformanceObserver((entries) => recordShifts(entries.getEntries())) + shiftObserver.observe({ type: "layout-shift" }) + const recordMutations = (entries: MutationRecord[]) => { + if (!state.running) return + const observedAtMs = performance.now() - state.startedAtPerformanceMs + if (observedAtMs > state.windowMs) return + const changed = entries.flatMap((entry) => [ + ...[...entry.addedNodes].map((node) => ({ type: "add", node: id(node) })), + ...[...entry.removedNodes].map((node) => ({ type: "remove", node: id(node) })), + ]) + if (changed.length) state.mutations.push({ observedAtMs, changed }) + } + const mutationObserver = new MutationObserver(recordMutations) + mutationObserver.observe(document.documentElement, { childList: true, subtree: true }) + state.stop = () => { + recordShifts(shiftObserver.takeRecords()) + recordMutations(mutationObserver.takeRecords()) + state.running = false + shiftObserver.disconnect() + mutationObserver.disconnect() + } + const sample = () => { + if (!state.running) return + setTimeout(() => { + if (!state.running) return + const observedAtMs = performance.now() - state.startedAtPerformanceMs + if (observedAtMs > state.windowMs) return + const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => + element.querySelector("[data-timeline-row]"), + ) + if (root) { + const view = root.getBoundingClientRect() + const rows = [...root.querySelectorAll("[data-timeline-key]")] + .map((element) => ({ + key: element.dataset.timelineKey, + node: id(element), + rect: element.getBoundingClientRect(), + })) + .filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom) + .map((item) => ({ + key: item.key, + node: item.node, + top: item.rect.top - view.top, + bottom: item.rect.bottom - view.top, + })) + const messages = [...root.querySelectorAll("[data-message-id]")] + .filter((element) => { + const rect = element.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }) + .map((element) => element.dataset.messageId!) + const spacer = root.querySelector('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect() + state.samples.push({ + observedAtMs, + root: id(root), + scrollTop: root.scrollTop, + scrollHeight: root.scrollHeight, + bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined, + last: messages.includes(last), + rows, + mounted: root.querySelectorAll("[data-timeline-key]").length, + center: document + .elementFromPoint(view.left + view.width / 2, view.top + view.height / 2) + ?.textContent?.slice(0, 80), + destination: messages.filter((messageID) => destinationIDs.has(messageID)), + source: messages.filter((messageID) => sourceIDs.has(messageID)), + }) + } else { + state.samples.push({ + observedAtMs, + root: undefined, + scrollTop: 0, + scrollHeight: 0, + bottomErrorPx: undefined, + last: false, + rows: [], + mounted: 0, + center: document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.textContent?.slice(0, 80), + destination: [], + source: [], + }) + } + requestAnimationFrame(sample) + }, 0) + } + document.addEventListener( + "click", + (event) => { + const link = event.target instanceof Element ? event.target.closest("a") : undefined + if (link?.getAttribute("href") !== targetHref) return + state.startedAtPerformanceMs = performance.now() + state.running = true + requestAnimationFrame(sample) + }, + { capture: true, once: true }, + ) + ;(window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash = state + }, input) +} + +export function layoutShiftSample(entry: Pick & { value: number }, started: number) { + if (entry.startTime < started) return + return { occurredAtMs: entry.startTime - started, value: entry.value } +} + +export async function waitForCachedRepaintWindow(page: Page, durationMs: number) { + await page.waitForFunction((durationMs) => { + const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash + return !!state?.running && performance.now() - state.startedAtPerformanceMs >= durationMs + }, durationMs) +} + +export async function collectCachedRepaintTrace(page: Page) { + return page.evaluate(() => { + const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash! + state.stop() + return state + }) +} + +export function summarizeCachedRepaintTrace(trace: CachedRepaintTrace) { + const roots = trace.samples.map((sample) => sample.root) + const bottomErrors = trace.samples.flatMap((sample) => + sample.bottomErrorPx === undefined ? [] : [Math.abs(sample.bottomErrorPx)], + ) + const category = (sample: CachedRepaintTrace["samples"][number]) => { + if (sample.source.length) return "source" + if (sample.root === undefined || sample.rows.length === 0) return "blank" + if (!sample.destination.length) return "unknown" + if (sample.last && Math.abs(sample.bottomErrorPx ?? Infinity) <= 1) return "correct" + return "wrongDestination" + } + return { + samples: trace.samples.length, + durationMs: trace.samples.at(-1)?.observedAtMs ?? 0, + firstSampleObservedMs: trace.samples[0]?.observedAtMs, + firstSampleCorrect: trace.samples[0] ? category(trace.samples[0]) === "correct" : false, + blankSamples: trace.samples.filter((sample) => category(sample) === "blank").length, + sourceSamples: trace.samples.filter((sample) => category(sample) === "source").length, + wrongDestinationSamples: trace.samples.filter((sample) => category(sample) === "wrongDestination").length, + unknownSamples: trace.samples.filter((sample) => category(sample) === "unknown").length, + rootChanges: roots.slice(1).filter((root, index) => root !== roots[index]).length, + mountedMin: trace.samples.length ? Math.min(...trace.samples.map((sample) => sample.mounted)) : 0, + mountedMax: Math.max(...trace.samples.map((sample) => sample.mounted)), + maxBottomErrorPx: Math.max(0, ...bottomErrors), + mutationBatches: trace.mutations.length, + addedNodes: trace.mutations.reduce( + (sum, batch) => sum + batch.changed.filter((change) => change.type === "add").length, + 0, + ), + removedNodes: trace.mutations.reduce( + (sum, batch) => sum + batch.changed.filter((change) => change.type === "remove").length, + 0, + ), + layoutShiftValueSum: trace.shifts.reduce((sum, shift) => sum + shift.value, 0), + maxLayoutShiftValue: Math.max(0, ...trace.shifts.map((shift) => shift.value)), + } +} + +export function compressCachedRepaintTrace(trace: CachedRepaintTrace) { + const samples: { + observedAtMs: number[] + state: Omit + }[] = [] + for (const sample of trace.samples) { + const { observedAtMs, ...state } = sample + const previous = samples.at(-1) + if (previous && JSON.stringify(previous.state) === JSON.stringify(state)) { + previous.observedAtMs.push(observedAtMs) + continue + } + samples.push({ observedAtMs: [observedAtMs], state }) + } + return { + timeOriginEpochMs: trace.timeOriginEpochMs, + startedAtPerformanceMs: trace.startedAtPerformanceMs, + windowMs: trace.windowMs, + summary: summarizeCachedRepaintTrace(trace), + samples, + mutations: trace.mutations, + shifts: trace.shifts, + } +} diff --git a/packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts b/packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts new file mode 100644 index 000000000000..48d4d3deb0d0 --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts @@ -0,0 +1,144 @@ +import type { Page } from "@playwright/test" +import { expectSessionTitle } from "../../utils/waits" +import { benchmark, expect, withBenchmarkPage } from "../benchmark" +import { fixture } from "./session-timeline-stress.fixture" +import { + createReviewDiffs, + installStressSessionTabs, + installTimelineSettings, + mockStressTimeline, + stressSessionHref, +} from "./timeline-test-helpers" +import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe" + +type Result = Awaited> + +benchmark("benchmarks cold and hot session tab switching", async ({ browser, report }, testInfo) => { + benchmark.setTimeout(180_000) + const results = { cold: [] as Result[], hot: [] as Result[] } + for (const mode of ["cold", "hot"] as const) { + for (let run = 0; run < 5; run++) { + results[mode].push( + await withBenchmarkPage(browser, `session-tab-switch-${mode}-${run}`, (page) => trial(page, mode), testInfo), + ) + } + } + report({ results, summary: summarize(results) }) +}) + +benchmark( + "benchmarks v2 session tab switching with and without the review pane", + async ({ browser, report }, testInfo) => { + benchmark.setTimeout(360_000) + const runs = Number(process.env.SESSION_TAB_SWITCH_RUNS ?? 5) + const results = { + closed: { cold: [] as Result[], hot: [] as Result[] }, + open: { cold: [] as Result[], hot: [] as Result[] }, + } + for (const reviewPane of ["closed", "open"] as const) { + for (const mode of ["cold", "hot"] as const) { + for (let run = 0; run < runs; run++) { + results[reviewPane][mode].push( + await withBenchmarkPage( + browser, + `session-tab-switch-v2-${reviewPane}-${mode}-${run}`, + (page) => trial(page, mode, { newLayoutDesigns: true, reviewPane }), + testInfo, + ), + ) + } + } + } + report({ results, summary: summarizeReviewPane(results) }, { runs, reviewDiffs: createReviewDiffs().length }) + }, +) + +async function trial( + page: Page, + mode: "cold" | "hot", + options?: { newLayoutDesigns?: boolean; reviewPane?: "closed" | "open" }, +) { + const reviewDiffs = options?.newLayoutDesigns ? createReviewDiffs() : undefined + await mockStressTimeline(page, { vcsDiff: reviewDiffs }) + if (options?.newLayoutDesigns) await installTimelineSettings(page) + await installStressSessionTabs(page) + if (mode === "hot") { + await page.goto(stressSessionHref(fixture.targetID)) + await expectSessionTitle(page, fixture.expected.targetTitle) + await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!) + await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle) + } else { + await page.goto(stressSessionHref(fixture.sourceID)) + await expectSessionTitle(page, fixture.expected.sourceTitle) + } + await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!) + if (options?.reviewPane === "open") { + await openReviewPane(page) + await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!) + } + + const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.info.id) + const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.info.id) + const lastID = fixture.expected.targetMessageIDs.at(-1)! + const href = stressSessionHref(fixture.targetID) + const result = await measureSessionSwitch(page, { + destinationIDs, + sourceIDs, + lastID, + href, + switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle), + }) + return result +} + +function summarize(results: Record<"cold" | "hot", Result[]>) { + const stats = (values: (number | null)[]) => { + const sorted = values.filter((value): value is number => value !== null).sort((a, b) => a - b) + return { + min: sorted[0] ?? null, + median: sorted[Math.floor(sorted.length / 2)] ?? null, + max: sorted.at(-1) ?? null, + missing: values.length - sorted.length, + } + } + return Object.fromEntries( + Object.entries(results).map(([mode, values]) => [ + mode, + { + firstDestinationObservedMs: stats(values.map((value) => value.firstDestinationObservedMs)), + firstCorrectObservedMs: stats(values.map((value) => value.firstCorrectObservedMs)), + stableObservedMs: stats(values.map((value) => value.stableObservedMs)), + }, + ]), + ) +} + +function summarizeReviewPane(results: Record<"closed" | "open", Record<"cold" | "hot", Result[]>>) { + return Object.fromEntries( + Object.entries(results).map(([reviewPane, values]) => [ + reviewPane, + summarize(values as Record<"cold" | "hot", Result[]>), + ]), + ) +} + +async function switchSession(page: Page, sessionID: string, title: string) { + const href = stressSessionHref(sessionID) + const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first() + await expect(tab).toBeVisible() + await tab.click() + await expectSessionTitle(page, title) +} + +async function openReviewPane(page: Page) { + await page.getByRole("button", { name: "Toggle review" }).click() + const panel = page.locator("#review-panel") + await expect(panel).toBeVisible() + // Text-based readiness works across review implementations; the legacy list mounts + // diff viewers lazily while V2 mounts the active preview eagerly. + await page.waitForFunction(() => { + const panel = document.querySelector("#review-panel") + const text = panel?.textContent ?? "" + return text.includes("generated-000.ts") && text.includes("+3") + }) +} diff --git a/packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts b/packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts new file mode 100644 index 000000000000..5298b9b0b7df --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts @@ -0,0 +1,59 @@ +export type SessionSwitchSample = { + observedAtMs: number + destination: string[] + source: string[] + hasVisibleRows: boolean + last: boolean + requiredPartVisible?: boolean + bottomAnchorRequired?: boolean + bottomErrorPx?: number + review?: { + fileHost: boolean + fileHostReplaced: boolean + header: string + replacedLevels: string[] + } +} + +export function classifySessionSwitch(samples: SessionSwitchSample[]) { + const firstDestination = samples.findIndex((sample) => sample.destination.length > 0) + const firstCorrect = samples.findIndex(isCorrectDestination) + const stable = samples.findIndex((_, index) => isStableSessionSwitch(samples.slice(index, index + 3))) + return { + firstDestinationObservedMs: samples[firstDestination]?.observedAtMs ?? null, + firstCorrectObservedMs: samples[firstCorrect]?.observedAtMs ?? null, + stableObservedMs: samples[stable + 2]?.observedAtMs ?? null, + wrongDestinationSamples: samples + .slice(firstDestination) + .filter((sample) => sample.destination.length > 0 && !sample.last).length, + blankSamples: samples.filter((sample) => !sample.hasVisibleRows).length, + unknownSamples: samples.filter( + (sample) => sample.hasVisibleRows && sample.destination.length === 0 && sample.source.length === 0, + ).length, + sourceSamples: samples.filter((sample) => sample.source.length > 0).length, + reviewFileHostMissingSamples: samples.filter((sample) => sample.review && !sample.review.fileHost).length, + reviewFileHostReplacedSamples: samples.filter((sample) => sample.review?.fileHostReplaced).length, + reviewHeaders: [...new Set(samples.flatMap((sample) => (sample.review ? [sample.review.header] : [])))], + reviewReplacedLevels: [...new Set(samples.flatMap((sample) => sample.review?.replacedLevels ?? []))], + } +} + +export function isCorrectDestination(sample: SessionSwitchSample) { + return ( + sample.destination.length > 0 && + sample.source.length === 0 && + sample.last && + sample.requiredPartVisible !== false && + (sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1) + ) +} + +export function isStableSessionSwitch(samples: SessionSwitchSample[]) { + return samples.length === 3 && samples.every(isCorrectDestination) +} + +export function isStableDestination(samples: Pick[]) { + return ( + samples.length === 3 && samples.every((sample) => sample.last && Math.abs(sample.bottomErrorPx ?? Infinity) <= 1) + ) +} diff --git a/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts b/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts new file mode 100644 index 000000000000..955da80367d7 --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts @@ -0,0 +1,226 @@ +import { expect, type Page } from "@playwright/test" +import { classifySessionSwitch, isStableDestination, type SessionSwitchSample } from "./session-tab-switch-metrics" + +type SessionSwitchProbe = { + samples: SessionSwitchSample[] + stop: () => void +} + +async function installSessionSwitchProbe( + page: Page, + input: { + destinationIDs: string[] + sourceIDs: string[] + lastID: string + requiredPartID?: string + requireBottomAnchor?: boolean + href: string + }, +) { + await page.evaluate(({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, href }) => { + const destination = new Set(destinationIDs) + const source = new Set(sourceIDs) + const samples: SessionSwitchSample[] = [] + let started: number | undefined + let running = true + const reviewLevels: Record = { + panel: "#review-panel", + tabs: '#review-panel [data-component="tabs"]', + body: '#review-panel [data-slot="session-review-v2-body"]', + review: '#review-panel [data-component="session-review-v2"]', + preview: '#review-panel [data-slot="session-review-v2-preview"]', + scroll: '#review-panel [data-slot="session-review-v2-diff-scroll"]', + file: '#review-panel [data-component="file"][data-mode="diff"]', + } + const initialReviewNodes: Record = {} + const sample = () => { + if (!running || started === undefined) return + setTimeout(() => { + if (!running || started === undefined) return + const observedAtMs = performance.now() - started + const reviewPanel = document.querySelector("#review-panel") + const reviewFile = reviewPanel?.querySelector('[data-component="file"][data-mode="diff"]') + const initialReviewFile = initialReviewNodes.file + const replacedLevels = Object.entries(reviewLevels).flatMap(([name, selector]) => { + const initial = initialReviewNodes[name] + if (!initial) return [] + const current = document.querySelector(selector) + return current && current !== initial ? [name] : [] + }) + const review = reviewPanel + ? { + fileHost: !!reviewFile, + fileHostReplaced: !!initialReviewFile && !!reviewFile && reviewFile !== initialReviewFile, + header: + reviewPanel + .querySelector('[data-slot="session-review-v2-file-header"]') + ?.textContent?.trim() ?? "", + replacedLevels, + } + : undefined + const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => + element.querySelector("[data-timeline-row]"), + ) + if (root) { + const view = root.getBoundingClientRect() + const visible = [...root.querySelectorAll("[data-message-id]")] + .filter((element) => { + const rect = element.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }) + .map((element) => element.dataset.messageId!) + const hasVisibleRows = [...root.querySelectorAll("[data-timeline-key]")].some((element) => { + const rect = element.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }) + const requiredPartVisible = requiredPartID + ? [...root.querySelectorAll("[data-timeline-part-id]")].some((element) => { + if (element.dataset.timelinePartId !== requiredPartID) return false + const rect = element.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom + }) + : undefined + const spacer = root.querySelector('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect() + samples.push({ + observedAtMs, + destination: visible.filter((id) => destination.has(id)), + source: visible.filter((id) => source.has(id)), + hasVisibleRows, + last: visible.includes(lastID), + requiredPartVisible, + bottomAnchorRequired: requireBottomAnchor !== false, + bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined, + review, + }) + } else { + samples.push({ + observedAtMs, + destination: [], + source: [], + hasVisibleRows: false, + last: false, + requiredPartVisible: requiredPartID ? false : undefined, + bottomAnchorRequired: requireBottomAnchor !== false, + review, + }) + } + requestAnimationFrame(sample) + }, 0) + } + document.addEventListener( + "click", + (event) => { + const link = event.target instanceof Element ? event.target.closest("a") : undefined + if (link?.getAttribute("href") !== href) return + started = performance.now() + for (const [name, selector] of Object.entries(reviewLevels)) { + initialReviewNodes[name] = document.querySelector(selector) + } + requestAnimationFrame(sample) + }, + { capture: true, once: true }, + ) + ;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = { + samples, + stop: () => { + running = false + }, + } + }, input) +} + +async function waitForStableSessionSwitch(page: Page) { + await page.waitForFunction(() => { + const samples = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.samples + if (!samples) return false + return samples.some((_, index) => { + const stable = samples.slice(index, index + 3) + return ( + stable.length === 3 && + stable.every( + (sample) => + sample.destination.length > 0 && + sample.source.length === 0 && + sample.last && + sample.requiredPartVisible !== false && + (sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1), + ) + ) + }) + }) +} + +async function collectSessionSwitchResult(page: Page) { + const samples = await page.evaluate(() => { + const probe = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe! + probe.stop() + return probe.samples + }) + return classifySessionSwitch(samples) +} + +export async function measureSessionSwitch( + page: Page, + input: { + destinationIDs: string[] + sourceIDs: string[] + lastID: string + requiredPartID?: string + requireBottomAnchor?: boolean + href: string + switch: () => Promise + }, +) { + const { switch: run, ...probe } = input + await installSessionSwitchProbe(page, probe) + try { + await run() + await waitForStableSessionSwitch(page) + return await collectSessionSwitchResult(page) + } finally { + await page.evaluate(() => { + ;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.stop() + }) + } +} + +export async function waitForStableTimeline(page: Page, lastID: string) { + const samples: Pick[] = [] + await expect + .poll( + async () => { + samples.push( + await page.evaluate( + (lastID) => + new Promise>((resolve) => { + requestAnimationFrame(() => + setTimeout(() => { + const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => + element.querySelector("[data-timeline-row]"), + ) + if (!root) { + resolve({ last: false }) + return + } + const view = root.getBoundingClientRect() + const last = [...root.querySelectorAll("[data-message-id]")].some((element) => { + if (element.dataset.messageId !== lastID) return false + const rect = element.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }) + const spacer = root + .querySelector('[data-timeline-row="bottom-spacer"]') + ?.getBoundingClientRect() + resolve({ last, bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined }) + }, 0), + ) + }), + lastID, + ), + ) + return isStableDestination(samples.slice(-3)) + }, + { timeout: 30_000, intervals: [0] }, + ) + .toBe(true) +} diff --git a/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts b/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts new file mode 100644 index 000000000000..a86a55cff252 --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts @@ -0,0 +1,504 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import type { Page } from "@playwright/test" +import { mockOpenCodeServer } from "../../utils/mock-server" +import { expectAppVisible, expectSessionTitle } from "../../utils/waits" +import { expect } from "../benchmark" + +const directory = "C:/OpenCode/TimelineStateRegression" +const projectID = "proj_timeline_state_regression" +const sessionID = "ses_timeline_state_regression" +const userMessageID = "msg_user_regression" +const assistantMessageID = "msg_assistant_regression" +const editPartID = "prt_0001_edit" +export const textPartID = "prt_9999_text" +const title = "Timeline collapse state regression" +const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } + +type EventPayload = { + directory: string + payload: Record +} + +const userMessage = { + info: { + id: userMessageID, + sessionID, + role: "user", + time: { created: 1700000000000 }, + summary: { diffs: [] }, + agent: "build", + model, + }, + parts: [ + { + id: "prt_user_text", + sessionID, + messageID: userMessageID, + type: "text", + text: "Please edit the file.", + }, + ], +} + +const editPart = { + id: editPartID, + sessionID, + messageID: assistantMessageID, + type: "tool", + callID: "call_edit_regression", + tool: "edit", + state: { + status: "completed", + input: { filePath: "src/regression.ts" }, + output: "Edited src/regression.ts", + title: "src/regression.ts", + metadata: { + filediff: { + file: "src/regression.ts", + additions: 1, + deletions: 1, + before: "export const value = 'before'\n", + after: "export const value = 'after'\n", + }, + diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n", + }, + time: { start: 1700000001000, end: 1700000002000 }, + }, +} + +const streamedTextPart = { + id: textPartID, + sessionID, + messageID: assistantMessageID, + type: "text", + text: "Streaming added a later assistant text part.", +} + +const assistantMessage = { + info: { + id: assistantMessageID, + sessionID, + role: "assistant", + time: { created: 1700000001000 }, + parentID: userMessageID, + modelID: model.modelID, + providerID: model.providerID, + mode: "build", + agent: "build", + path: { cwd: directory, root: directory }, + cost: 0.01, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + variant: "max", + }, + parts: [editPart], +} + +export async function setupTimelineBenchmark( + page: Page, + options: { + historyTurns: number + eventBatch: number + newLayoutDesigns?: boolean + vcsDiff?: unknown[] + turnDiffs?: unknown[] + }, +) { + const events: EventPayload[] = [] + let eventBatch = options.eventBatch + const currentUserMessage = options.turnDiffs + ? { ...userMessage, info: { ...userMessage.info, summary: { diffs: options.turnDiffs } } } + : userMessage + await mockOpenCodeServer(page, { + directory, + project: project(), + provider: provider(), + sessions: [session()], + vcsDiff: options.vcsDiff, + pageMessages: () => ({ + items: [ + ...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index)).flat(), + currentUserMessage, + assistantMessage, + ], + }), + events: () => events.splice(0, eventBatch), + eventRetry: 16, + }) + await page.addInitScript( + (input) => { + localStorage.setItem( + "settings.v3", + JSON.stringify({ + general: { + newLayoutDesigns: input.newLayoutDesigns, + editToolPartsExpanded: true, + shellToolPartsExpanded: true, + showReasoningSummaries: true, + }, + }), + ) + }, + { newLayoutDesigns: options.newLayoutDesigns ?? false }, + ) + await page.setViewportSize({ width: 1366, height: 768 }) + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + const text = page.locator(`[data-timeline-part-id="${textPartID}"]`).first() + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + await expectAppVisible(scroller) + return { + scroller, + text, + transport: { + enqueue(payload: EventPayload | EventPayload[]) { + events.push(...(Array.isArray(payload) ? payload : [payload])) + }, + pendingCount() { + return events.length + }, + releaseAll() { + eventBatch = events.length + }, + }, + async scrollToBottom() { + await scroller.evaluate((element) => { + element.scrollTop = element.scrollHeight + }) + }, + async waitForStableGeometry() { + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1) + await page.waitForFunction((partID) => { + const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => + element.querySelector(`[data-timeline-part-id="${partID}"]`), + ) + if (!root) return false + return new Promise((resolve) => { + const height = root.scrollHeight + requestAnimationFrame(() => + requestAnimationFrame(() => + resolve(root.scrollHeight === height && root.scrollHeight - root.clientHeight - root.scrollTop <= 1), + ), + ) + }) + }, textPartID) + }, + } +} + +export function buildInitialStreamEvent(deltaCount: number): EventPayload { + return { + directory, + payload: { + type: "message.part.updated", + properties: { + part: { + ...streamedTextPart, + text: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``, + }, + }, + }, + } +} + +export function buildStreamDeltaEvents(deltaCount: number): EventPayload[] { + return Array.from({ length: deltaCount }, (_, index) => ({ + directory, + payload: { + type: "message.part.delta", + properties: { + messageID: assistantMessageID, + partID: textPartID, + field: "text", + delta: streamChunk(index + 1, deltaCount + 1), + }, + }, + })) +} + +function performanceTurn(index: number) { + const suffix = String(index).padStart(4, "0") + const userID = `msg_0000_${suffix}_a_user` + const assistantID = `msg_0000_${suffix}_b_assistant` + const before = historicalSource(index, false) + const after = historicalSource(index, true) + const parts = [ + ...(index % 5 === 0 + ? [ + { + id: `prt_0000_${suffix}_reasoning`, + sessionID, + messageID: assistantID, + type: "reasoning", + text: `Reviewing the existing implementation. ${"constraint analysis ".repeat(20)}`, + time: { start: 1690000001000 + index * 2_000, end: 1690000001200 + index * 2_000 }, + }, + ] + : []), + { + id: `prt_0000_${suffix}_assistant`, + sessionID, + messageID: assistantID, + type: "text", + text: historicalMarkdown(index), + }, + ...(index % 8 === 0 + ? [ + { + id: `prt_0000_${suffix}_edit`, + sessionID, + messageID: assistantID, + type: "tool", + callID: `call_0000_${suffix}_edit`, + tool: "edit", + state: { + status: "completed", + input: { filePath: `src/history-${index}.ts` }, + output: `Edited src/history-${index}.ts`, + title: `src/history-${index}.ts`, + metadata: { + filediff: { file: `src/history-${index}.ts`, additions: 48, deletions: 48, before, after }, + }, + time: { start: 1690000001200 + index * 2_000, end: 1690000001400 + index * 2_000 }, + }, + }, + ] + : []), + ...(index % 12 === 0 + ? [ + { + id: `prt_0000_${suffix}_write`, + sessionID, + messageID: assistantID, + type: "tool", + callID: `call_0000_${suffix}_write`, + tool: "write", + state: { + status: "completed", + input: { filePath: `src/generated-${index}.tsx`, content: after }, + output: `Wrote src/generated-${index}.tsx`, + title: `src/generated-${index}.tsx`, + metadata: { + filediff: { file: `src/generated-${index}.tsx`, additions: 32, deletions: 0, before: "", after }, + }, + time: { start: 1690000001400 + index * 2_000, end: 1690000001500 + index * 2_000 }, + }, + }, + ] + : []), + ...(index % 16 === 0 + ? [ + { + id: `prt_0000_${suffix}_patch`, + sessionID, + messageID: assistantID, + type: "tool", + callID: `call_0000_${suffix}_patch`, + tool: "apply_patch", + state: { + status: "completed", + input: { patchText: realisticPatch(index) }, + output: "Success. Updated src/components/SessionCard.tsx", + title: "src/components/SessionCard.tsx", + metadata: { + files: [ + { + filePath: "src/components/SessionCard.tsx", + relativePath: "src/components/SessionCard.tsx", + type: "update", + additions: 8, + deletions: 3, + patch: realisticPatch(index), + before, + after, + }, + ], + }, + time: { start: 1690000001500 + index * 2_000, end: 1690000001700 + index * 2_000 }, + }, + }, + ] + : []), + ] + return [ + { + info: { + id: userID, + sessionID, + role: "user", + time: { created: 1690000000000 + index * 2_000 }, + summary: { diffs: [] }, + agent: "build", + model, + }, + parts: [ + { + id: `prt_0000_${suffix}_user`, + sessionID, + messageID: userID, + type: "text", + text: `Historical prompt ${index}`, + }, + ], + }, + { + info: { + id: assistantID, + sessionID, + role: "assistant", + time: { created: 1690000001000 + index * 2_000, completed: 1690000001500 + index * 2_000 }, + parentID: userID, + modelID: model.modelID, + providerID: model.providerID, + mode: "build", + agent: "build", + path: { cwd: directory, root: directory }, + cost: 0.01, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + variant: "max", + finish: "stop", + }, + parts, + }, + ] +} + +function historicalMarkdown(index: number) { + const code = `import { For, Show, createSignal } from "solid-js" + +type SessionRow = { id: string; title: string; active: boolean } + +export function SessionList(props: { rows: SessionRow[] }) { + const [selected, setSelected] = createSignal() + return ( +
+ {(row) => ( + + )} +
+ ) +}` + return `## Session renderer review ${index} + +The active session keeps **semantic row identity** while reconciling measured content. See [Solid documentation](https://docs.solidjs.com/) and the inline \`measureElement(node)\` call. + +| Concern | Current behavior | Verification | +| --- | --- | --- | +| streaming | appends Markdown blocks | painted frames | +| geometry | anchors visible rows | DOM coordinates | +| tools | preserves expanded state | keyed remount probe | + +> Long sessions combine Markdown, syntax highlighting, tool output, and asynchronously rendered diffs. + +${index % 4 === 0 ? `\`\`\`tsx\n${code}\n\`\`\`\n\n\`\`\`bash\nbun typecheck\nbun test --preload ./happydom.ts ./src/pages/session\ngit diff --check\n\`\`\`` : "- preserve the viewport anchor\n- avoid replacing stable Markdown nodes\n- process provider deltas without blocking input"}` +} + +function historicalSource(index: number, updated: boolean) { + const method = updated ? "toLocaleUpperCase(props.locale)" : "toUpperCase()" + const limit = updated ? 24 : 20 + return `import { createMemo, For } from "solid-js" + +type Message = { + id: string + role: "user" | "assistant" + text: string + tokens: { input: number; output: number } +} + +export function MessageSummary(props: { messages: Message[]; locale: string }) { + const visible = createMemo(() => props.messages.filter((message) => message.text.trim()).slice(-${limit})) + const total = createMemo(() => visible().reduce((sum, message) => sum + message.tokens.output, 0)) + return ( +
+
{total().toLocaleString(props.locale)} output tokens
+ {(message) =>

{message.text.${method}}

}
+
+ ) +} +` +} + +function realisticPatch(index: number) { + return `*** Begin Patch +*** Update File: src/components/SessionCard.tsx +@@ +-const title = props.session.title.toUpperCase() +-const messages = props.messages.slice(-20) ++const title = props.session.title.toLocaleUpperCase(props.locale) ++const messages = props.messages.filter((message) => message.text.trim()).slice(-24) ++const outputTokens = messages.reduce((sum, message) => sum + message.tokens.output, 0) +@@ +-

{title}

++

{title}

++ {outputTokens.toLocaleString(props.locale)} output tokens +*** End Patch` +} + +export function streamChunk(index: number, count: number) { + if (index === 0) return `\n\n## Implementation plan\n\nStreaming **bold analysis` + if (index === count - 1) + return `\n\`\`\`\n\n## Verification\n\n- **Typecheck:** passed\n- **Timeline geometry:** stable\n- **Streaming output:** benchmark-complete ` + + const section = Math.floor(index / 18) + 1 + const fragments = [ + ` continues across three`, + ` or four word`, + ` provider deltas and`, + ` closes in this fragment**. \n\n`, + `| Concern | State`, + ` | Verification |\n|`, + ` --- | ---`, + ` | --- |\n|`, + ` markdown | incremental |`, + ` painted frames | \n\n`, + `\`\`\`tsx\nconst row: SessionRow`, + ` = rows[index] ??`, + ` fallback\nconst title =`, + ` row.title.toLocaleUpperCase(locale)\n`, + `const selected = createMemo(()`, + ` => row.id ===`, + ` activeID()) // stream-${index}\n`, + `// stream-${index}\n\`\`\`\n\n### Iteration ${section}\n\nStreaming **bold analysis`, + ] + return fragments[(index - 1) % fragments.length]! +} + +function project() { + return { + id: projectID, + worktree: directory, + vcs: "git", + name: "timeline-state-regression", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + } +} + +function session() { + return { + id: sessionID, + slug: "timeline-state-regression", + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + } +} + +function provider() { + return { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "claude-opus-4-6" }, + } +} diff --git a/packages/app/e2e/performance/timeline/session-timeline-benchmark.spec.ts b/packages/app/e2e/performance/timeline/session-timeline-benchmark.spec.ts new file mode 100644 index 000000000000..1a52bc8aa3ca --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-timeline-benchmark.spec.ts @@ -0,0 +1,306 @@ +import type { Page } from "@playwright/test" +import { benchmark, benchmarkDiagnostics, expect } from "../benchmark" +import { + buildInitialStreamEvent, + buildStreamDeltaEvents, + setupTimelineBenchmark, + textPartID, +} from "./session-timeline-benchmark.fixture" +import { startTimelineProfile } from "./session-timeline-profile" +import { createReviewDiffs } from "./timeline-test-helpers" +import { + collectTimelineStreamMetrics, + installTimelineStreamProbe, + startTimelineStreamProbe, +} from "./session-timeline-stream-probe" + +type TimelineStreamOptions = { + newLayoutDesigns?: boolean + reviewDiffs?: boolean + reviewPane?: boolean +} + +type ReviewPaneSample = { + observedAtMs: number + panelVisible: boolean + header: string + diffViewers: number + diffLines: number + codeBlocks: number + ready: boolean +} + +type ReviewPaneProbe = { + samples: ReviewPaneSample[] + start: () => void + stop: () => void +} + +const reviewReadyStreak = 3 + +benchmark.describe("performance: session timeline streaming", () => { + benchmark("streams assistant text without remounting or oscillating", async ({ page, report }) => { + benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000) + const result = await runTimelineStreamBenchmark(page, {}) + report(result.metrics, result.context) + }) + + benchmark("streams assistant text in v2 with review pane closed", async ({ page, report }) => { + benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000) + const result = await runTimelineStreamBenchmark(page, { newLayoutDesigns: true }) + report(result.metrics, result.context) + }) + + benchmark("streams assistant text in v2 with review diffs and pane closed", async ({ page, report }) => { + benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000) + const result = await runTimelineStreamBenchmark(page, { newLayoutDesigns: true, reviewDiffs: true }) + report(result.metrics, result.context) + }) + + benchmark("streams assistant text in v2 with review pane open", async ({ page, report }) => { + benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000) + const result = await runTimelineStreamBenchmark(page, { newLayoutDesigns: true, reviewPane: true }) + report(result.metrics, result.context) + }) +}) + +benchmark.describe("performance: review pane", () => { + benchmark("loads v2 review diffs and switches active files", async ({ page, report }) => { + benchmark.setTimeout(240_000) + const historyTurns = Number(process.env.REVIEW_PANE_HISTORY_TURNS ?? 72) + const diffs = createReviewDiffs() + const fixture = await setupTimelineBenchmark(page, { + historyTurns, + eventBatch: 1, + newLayoutDesigns: true, + vcsDiff: diffs, + }) + + fixture.transport.enqueue(buildInitialStreamEvent(1)) + await expect(fixture.text).toBeVisible() + await expect(fixture.text).toContainText("Implementation plan") + await fixture.scrollToBottom() + await fixture.waitForStableGeometry() + + const open = await measureReviewPaneLoad(page, diffs[0]!.file) + const switches = [] + for (const diff of diffs.slice(1, 4)) switches.push(await measureReviewNextFile(page, diff.file)) + + report( + { + open, + switches, + }, + { + historyTurns, + reviewDiffs: diffs.length, + }, + ) + }) +}) + +async function runTimelineStreamBenchmark(page: Page, options: TimelineStreamOptions) { + const completionTimeoutMs = Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + const cpuThrottle = Number(process.env.TIMELINE_CPU_THROTTLE ?? 30) + const deltaCount = Number(process.env.TIMELINE_DELTA_COUNT ?? 160) + const historyTurns = Number(process.env.TIMELINE_HISTORY_TURNS ?? 320) + const eventBatch = Number(process.env.TIMELINE_EVENT_BATCH ?? 1) + const minimal = process.env.TIMELINE_MINIMAL === "1" + const profileCPU = process.env.TIMELINE_CPU_PROFILE === "1" + const profileVisual = !minimal && profileCPU && process.env.TIMELINE_VISUAL_PROFILE !== "0" + const diffs = options.reviewDiffs || options.reviewPane ? createReviewDiffs() : undefined + const fixture = await setupTimelineBenchmark(page, { + historyTurns, + eventBatch, + newLayoutDesigns: options.newLayoutDesigns, + // Turn diffs exercise timeline data cost; the pane-open scenario serves the same + // diffs through the default git mode so it works across review implementations. + turnDiffs: options.reviewDiffs ? diffs : undefined, + vcsDiff: options.reviewPane ? diffs : undefined, + }) + + fixture.transport.enqueue(buildInitialStreamEvent(deltaCount)) + const contentStart = performance.now() + await expect(fixture.text).toBeVisible() + await expect(fixture.text).toContainText("Implementation plan") + const initialContentObservedMs = performance.now() - contentStart + await fixture.scrollToBottom() + await fixture.waitForStableGeometry() + + const reviewPane = options.reviewPane && diffs ? await measureReviewPaneLoad(page, diffs[0]!.file) : undefined + if (reviewPane) await fixture.waitForStableGeometry() + + const profile = await startTimelineProfile(page, { cpuThrottle, profileCPU }) + await installTimelineStreamProbe(page, { textPartID, finalIndex: deltaCount, profileVisual, minimal }) + const deltas = buildStreamDeltaEvents(deltaCount) + await startTimelineStreamProbe(page) + fixture.transport.enqueue(deltas) + + await page.waitForFunction( + (finalIndex) => + ( + window as Window & { + __timelineStreamBenchmark?: { applied: { index: number }[] } + } + ).__timelineStreamBenchmark?.applied.some((value) => value.index === finalIndex), + deltaCount, + { timeout: completionTimeoutMs }, + ) + await expect(fixture.text).toContainText("benchmark-complete") + await expect(fixture.text).toContainText("Streaming") + await fixture.waitForStableGeometry() + const metrics = await collectTimelineStreamMetrics(page, { + textPartID, + finalIndex: deltaCount, + navigations: benchmarkDiagnostics(page).navigations, + }) + const delivered = deltas.length - fixture.transport.pendingCount() + await profile.stop() + + const result = { + metrics: { + endToEndInitialContentObservedMs: initialContentObservedMs, + ...metrics, + deliveredDeltas: delivered, + pendingDeltas: fixture.transport.pendingCount(), + reviewPane: reviewPane ?? null, + }, + context: { + cpuThrottle, + profileCPU, + profileVisual, + minimal, + queuedDeltas: deltas.length, + historyTurns, + eventBatch, + newLayoutDesigns: options.newLayoutDesigns === true, + reviewPane: options.reviewPane === true ? "open" : "closed", + reviewDiffs: diffs?.length ?? 0, + }, + } + + await profile.reset() + return result +} + +async function measureReviewPaneLoad(page: Page, file: string) { + // Default git mode reads the mocked /vcs/diff data, so opening the pane is enough + // and the flow works across review pane implementations. + await installReviewPaneProbe(page, { file }) + await startReviewPaneProbe(page) + await page.getByRole("button", { name: "Toggle review" }).click() + await expect(page.locator("#review-panel")).toBeVisible() + return collectReviewPaneProbe(page) +} + +async function measureReviewNextFile(page: Page, file: string) { + await installReviewPaneProbe(page, { file }) + await startReviewPaneProbe(page) + await page.getByRole("button", { name: "Next file" }).click() + return collectReviewPaneProbe(page) +} + +async function installReviewPaneProbe(page: Page, input: { file: string }) { + await page.evaluate((input) => { + const samples: ReviewPaneSample[] = [] + const basename = input.file.split(/[\\/]/).at(-1) ?? input.file + let started: number | undefined + let running = true + + const paneState = () => { + const panel = document.querySelector("#review-panel") + const review = panel?.querySelector('[data-component="session-review-v2"]') + const rect = (review ?? panel)?.getBoundingClientRect() + const text = panel?.textContent ?? "" + const previewHeader = panel?.querySelector( + '[data-slot="session-review-v2-file-header"]', + )?.textContent + const header = previewHeader ?? text + const viewers = panel ? [...panel.querySelectorAll('[data-component="file"][data-mode="diff"]')] : [] + const codeBlocks = panel?.querySelectorAll("code").length ?? 0 + const diffLines = viewers.reduce( + (sum, viewer) => + sum + + (viewer.shadowRoot?.querySelectorAll("[data-line]").length ?? viewer.querySelectorAll("[data-line]").length), + 0, + ) + const panelVisible = + !!panel && panel.getAttribute("aria-hidden") !== "true" && !!rect && rect.width > 0 && rect.height > 0 + return { + panelVisible, + header: header.slice(0, 500), + diffViewers: viewers.length, + diffLines, + codeBlocks, + ready: + panelVisible && + header.includes(basename) && + (viewers.length > 0 || text.includes("+3") || diffLines > 0 || codeBlocks > 0), + } + } + + const sample = () => { + if (!running || started === undefined) return + requestAnimationFrame(() => { + setTimeout(() => { + if (!running || started === undefined) return + samples.push({ observedAtMs: performance.now() - started, ...paneState() }) + if (performance.now() - started < 10_000) sample() + }, 0) + }) + } + + ;(window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe = { + samples, + start: () => { + started = performance.now() + performance.mark("opencode.review-pane.click") + sample() + }, + stop: () => { + running = false + }, + } + }, input) +} + +async function startReviewPaneProbe(page: Page) { + await page.evaluate(() => { + ;(window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe!.start() + }) +} + +async function collectReviewPaneProbe(page: Page) { + await page.waitForFunction((streak) => { + const samples = (window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe?.samples + if (!samples) return false + return samples.some((_, index) => { + const stable = samples.slice(index, index + streak) + return stable.length === streak && stable.every((sample) => sample.ready) + }) + }, reviewReadyStreak) + + const samples = await page.evaluate(() => { + const probe = (window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe! + probe.stop() + return probe.samples + }) + return { summary: summarizeReviewPaneSamples(samples), samples } +} + +function summarizeReviewPaneSamples(samples: ReviewPaneSample[]) { + const firstReady = samples.find((sample) => sample.ready) + const stableIndex = samples.findIndex((_, index) => { + const stable = samples.slice(index, index + reviewReadyStreak) + return stable.length === reviewReadyStreak && stable.every((sample) => sample.ready) + }) + return { + samples: samples.length, + firstReadyObservedMs: firstReady?.observedAtMs ?? null, + stableReadyObservedMs: stableIndex === -1 ? null : samples[stableIndex + reviewReadyStreak - 1]!.observedAtMs, + notReadySamples: samples.filter((sample) => !sample.ready).length, + maxDiffViewers: Math.max(0, ...samples.map((sample) => sample.diffViewers)), + maxDiffLines: Math.max(0, ...samples.map((sample) => sample.diffLines)), + maxCodeBlocks: Math.max(0, ...samples.map((sample) => sample.codeBlocks)), + } +} diff --git a/packages/app/e2e/performance/timeline/session-timeline-profile.ts b/packages/app/e2e/performance/timeline/session-timeline-profile.ts new file mode 100644 index 000000000000..e1689498c196 --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-timeline-profile.ts @@ -0,0 +1,40 @@ +import type { CDPSession, Page } from "@playwright/test" + +export async function startTimelineProfile(page: Page, options: { cpuThrottle: number; profileCPU: boolean }) { + const cdp = await page.context().newCDPSession(page) + if (options.cpuThrottle > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: options.cpuThrottle }) + if (options.profileCPU) { + await cdp.send("Profiler.enable") + await cdp.send("Profiler.setSamplingInterval", { interval: 100 }) + await cdp.send("Profiler.start") + } + return { + async stop() { + if (!options.profileCPU) return + const result = await cdp.send("Profiler.stop") + const self = new Map() + result.profile.samples?.forEach((id, index) => { + const duration = (result.profile.timeDeltas?.[index] ?? 0) / 1_000 + self.set(id, (self.get(id) ?? 0) + duration) + }) + console.log( + "timeline cpu profile", + JSON.stringify( + result.profile.nodes + .map((node) => ({ + function: node.callFrame.functionName || "(anonymous)", + url: node.callFrame.url, + line: node.callFrame.lineNumber + 1, + selfMs: self.get(node.id) ?? 0, + })) + .filter((node) => node.selfMs > 1) + .sort((a, b) => b.selfMs - a.selfMs) + .slice(0, 40), + ), + ) + }, + async reset() { + if (options.cpuThrottle > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: 1 }) + }, + } +} diff --git a/packages/app/e2e/performance/timeline/session-timeline-stream-probe.ts b/packages/app/e2e/performance/timeline/session-timeline-stream-probe.ts new file mode 100644 index 000000000000..a3cd698cde4f --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-timeline-stream-probe.ts @@ -0,0 +1,547 @@ +import type { Page } from "@playwright/test" + +const STREAM_MARKER_PATTERN = "stream-(\\d+)" +const STREAM_FRAGMENT_COUNT = 18 + +type TimelineProbeState = { + started: number + ended: number + profileVisual: boolean + minimal: boolean + frames: number[] + frameAt: number[] + applied: { at: number; index: number }[] + geometry: { + scrollTop: number + scrollHeight: number + clientHeight: number + distance: number + virtualHeight: number + headerHeight: number + }[] + blanks: number + longTasks: number[] + layoutShifts: number[] + visibleMounts: number + visibleUnmounts: number + visibleRows: Set + visibleSubtreeMounts: string[] + visibleSubtreeUnmounts: string[] + visibleSubtreeReplacements: number + visibleSubtreeDropouts: string[] + visibleSubtrees: Map + subtreeKeys: WeakMap + maxOverlap: number + maxGap: number + maxPartTopMovement: number + previousPartTop: number + slowFrames: { + duration: number + index: number + phase: "stream" | "boundary" | "complete" | "unknown" + tokenSpans: number + blocks: number + codeBlocks: number + height: number + distance: number + }[] + scroll: { + calls: number + callNoops: number + sameFrameCalls: number + assignments: number + assignmentNoops: number + lastCallFrame: number + frame: number + } + row: HTMLElement + markdown: HTMLElement + running: boolean + previous: number + cleanup: () => void + start: () => void +} + +export async function installTimelineStreamProbe( + page: Page, + options: { textPartID: string; finalIndex: number; profileVisual: boolean; minimal: boolean }, +) { + await page.evaluate( + ({ textPartID, finalIndex, profileVisual, minimal, markerPattern, fragmentCount }) => { + const part = document.querySelector(`[data-timeline-part-id="${textPartID}"]`) + const row = part?.closest("[data-timeline-row]") + const markdown = part?.querySelector('[data-component="markdown"]') + const root = part?.closest(".scroll-view__viewport") + if (!part || !row || !markdown || !root) throw new Error("missing streaming benchmark nodes") + const viewport = root.getBoundingClientRect() + const state: TimelineProbeState = { + started: 0, + ended: Infinity, + profileVisual, + minimal, + frames: [], + frameAt: [], + applied: [], + geometry: [], + blanks: 0, + longTasks: [], + layoutShifts: [], + visibleMounts: 0, + visibleUnmounts: 0, + visibleRows: new Set( + [...root.querySelectorAll("[data-timeline-key]")].filter((element) => { + const rect = element.getBoundingClientRect() + return rect.bottom > viewport.top && rect.top < viewport.bottom + }), + ), + visibleSubtreeMounts: [], + visibleSubtreeUnmounts: [], + visibleSubtreeReplacements: 0, + visibleSubtreeDropouts: [], + visibleSubtrees: new Map(), + subtreeKeys: new WeakMap(), + maxOverlap: 0, + maxGap: 0, + maxPartTopMovement: 0, + previousPartTop: part.getBoundingClientRect().top, + slowFrames: [], + scroll: { + calls: 0, + callNoops: 0, + sameFrameCalls: 0, + assignments: 0, + assignmentNoops: 0, + lastCallFrame: -1, + frame: 0, + }, + row, + markdown, + running: false, + previous: 0, + cleanup: () => {}, + start: () => {}, + } + ;(window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark = state + const scrollTo = Element.prototype.scrollTo + const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")! + if (profileVisual) { + Element.prototype.scrollTo = function (...args) { + state.scroll.calls += 1 + const top = typeof args[0] === "object" ? args[0]?.top : args[1] + if (typeof top === "number") { + const target = Math.min(top, this.scrollHeight - this.clientHeight) + if (Math.abs(this.scrollTop - target) < 1) state.scroll.callNoops += 1 + } + if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1 + state.scroll.lastCallFrame = state.scroll.frame + return scrollTo.apply(this, args) + } + Object.defineProperty(Element.prototype, "scrollTop", { + configurable: true, + get: scrollTop.get, + set(value) { + state.scroll.assignments += 1 + if (Math.abs(this.scrollTop - value) < 1) state.scroll.assignmentNoops += 1 + scrollTop.set!.call(this, value) + }, + }) + } + + const recordLongTasks = (entries: PerformanceEntry[]) => { + if (!state.running) return + state.longTasks.push( + ...entries + .filter((entry) => entry.startTime >= state.started && entry.startTime <= state.ended) + .map((entry) => entry.duration), + ) + } + const longTaskObserver = new PerformanceObserver((list) => recordLongTasks(list.getEntries())) + longTaskObserver.observe({ type: "longtask" }) + const recordLayoutShifts = (entries: PerformanceEntry[]) => { + if (!state.running) return + state.layoutShifts.push( + ...entries + .map((entry) => { + const shift = entry as LayoutShiftEntry + if (shift.startTime < state.started || shift.hadRecentInput) return + return shift.value + }) + .filter((value): value is number => value !== undefined), + ) + } + const layoutShiftObserver = profileVisual + ? new PerformanceObserver((list) => recordLayoutShifts(list.getEntries())) + : undefined + layoutShiftObserver?.observe({ type: "layout-shift", buffered: true }) + + const visible = (element: Element) => { + const rect = element.getBoundingClientRect() + const viewport = root.getBoundingClientRect() + const style = getComputedStyle(element) + return ( + element.isConnected && + rect.width > 0 && + rect.height > 0 && + rect.bottom > viewport.top && + rect.top < viewport.bottom && + style.display !== "none" && + style.visibility !== "hidden" && + Number(style.opacity) > 0 + ) + } + const critical = [ + "[data-timeline-part-id]", + '[data-component="edit-content"]', + '[data-component="apply-patch-file-diff"]', + '[data-component="file"]', + '[data-component="markdown-code"]', + "[data-markdown-block]", + ].join(",") + const describe = (element: Element) => { + const cached = state.subtreeKeys.get(element) + if (!element.isConnected && cached) return cached + const part = element.closest("[data-timeline-part-id]")?.dataset.timelinePartId ?? "unknown" + const block = element + .closest("[data-markdown-key]") + ?.dataset.markdownKey?.replace(/:(?:code|full|live)$/, "") + const component = + element.getAttribute("data-component") ?? element.getAttribute("data-markdown-block") ?? element.tagName + const key = `${part}:${block ?? "root"}:${component}` + state.subtreeKeys.set(element, key) + return key + } + const recordMutations = (records: MutationRecord[]) => { + if (!state.running) return + records.forEach((record) => { + record.addedNodes.forEach((node) => { + if (node instanceof HTMLElement && node.matches("[data-timeline-key]") && visible(node)) { + state.visibleMounts += 1 + state.visibleRows.add(node) + } + if (!(node instanceof Element)) return + const added = [node, ...node.querySelectorAll(critical)].filter((element) => element.matches(critical)) + added.forEach((element) => { + if (visible(element)) state.visibleSubtreeMounts.push(describe(element)) + }) + }) + record.removedNodes.forEach((node) => { + if (node instanceof HTMLElement && node.matches("[data-timeline-key]") && state.visibleRows.delete(node)) + state.visibleUnmounts += 1 + if (!(node instanceof Element)) return + const removed = [node, ...node.querySelectorAll(critical)].filter((element) => element.matches(critical)) + removed.forEach((element) => { + const key = describe(element) + if (state.visibleSubtrees.get(key) === element) state.visibleSubtreeUnmounts.push(key) + }) + }) + }) + } + const mutationObserver = profileVisual ? new MutationObserver(recordMutations) : undefined + mutationObserver?.observe(root, { childList: true, subtree: true }) + const currentPart = () => root.querySelector(`[data-timeline-part-id="${textPartID}"]`) + const observeProgress = (at: number) => { + if (!state.running) return + const content = currentPart()?.textContent ?? "" + const index = content.includes("benchmark-complete") + ? finalIndex + : Number(content.match(new RegExp(markerPattern, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1) + if (index >= 0 && index !== state.applied.at(-1)?.index) state.applied.push({ at, index }) + } + const progressObserver = new MutationObserver(() => observeProgress(performance.now())) + progressObserver.observe(root, { characterData: true, childList: true, subtree: true }) + state.cleanup = () => { + recordLongTasks(longTaskObserver.takeRecords()) + recordLayoutShifts(layoutShiftObserver?.takeRecords() ?? []) + recordMutations(mutationObserver?.takeRecords() ?? []) + if (progressObserver.takeRecords().length) observeProgress(performance.now()) + longTaskObserver.disconnect() + layoutShiftObserver?.disconnect() + mutationObserver?.disconnect() + progressObserver.disconnect() + if (!profileVisual) return + Element.prototype.scrollTo = scrollTo + Object.defineProperty(Element.prototype, "scrollTop", scrollTop) + } + + const sample = (now: number) => { + if (!state.running) return + state.frameAt.push(now) + observeProgress(now) + if (minimal) { + state.frames.push(now - state.previous) + state.previous = now + requestAnimationFrame(sample) + return + } + setTimeout(() => { + if (!state.running) return + state.scroll.frame += 1 + const duration = now - state.previous + state.frames.push(duration) + state.previous = now + const virtualRoot = root.querySelector("[data-timeline-virtual-content]") + const header = root.querySelector("[data-session-title]") + state.geometry.push({ + scrollTop: root.scrollTop, + scrollHeight: root.scrollHeight, + clientHeight: root.clientHeight, + distance: root.scrollHeight - root.clientHeight - root.scrollTop, + virtualHeight: virtualRoot?.getBoundingClientRect().height ?? 0, + headerHeight: header?.getBoundingClientRect().height ?? 0, + }) + const viewport = root.getBoundingClientRect() + if (profileVisual) { + const visibleRows = [...root.querySelectorAll("[data-timeline-key]")] + .map((element) => ({ element, rect: element.getBoundingClientRect() })) + .filter((item) => item.rect.bottom > viewport.top && item.rect.top < viewport.bottom) + .sort((a, b) => a.rect.top - b.rect.top) + state.visibleRows = new Set(visibleRows.map((item) => item.element)) + const rows = visibleRows.map((item) => item.rect) + rows.slice(1).forEach((rect, index) => { + const previous = rows[index]! + state.maxOverlap = Math.max(state.maxOverlap, previous.bottom - rect.top) + state.maxGap = Math.max(state.maxGap, rect.top - previous.bottom) + }) + const partTop = part.getBoundingClientRect().top + state.maxPartTopMovement = Math.max(state.maxPartTopMovement, Math.abs(partTop - state.previousPartTop)) + state.previousPartTop = partTop + } + const visibleRow = [...root.querySelectorAll("[data-timeline-row]")].some((element) => { + const rect = element.getBoundingClientRect() + return rect.bottom > viewport.top && rect.top < viewport.bottom + }) + if (!visibleRow) state.blanks += 1 + if (profileVisual) { + const subtrees = new Map() + const visibleSubtrees = new Map() + root.querySelectorAll(critical).forEach((element) => { + const key = describe(element) + const rect = element.getBoundingClientRect() + const style = getComputedStyle(element) + const rendered = + element.isConnected && + rect.width > 0 && + rect.height > 0 && + style.display !== "none" && + style.visibility !== "hidden" && + Number(style.opacity) > 0 + subtrees.set(key, { element, rendered }) + if (rendered && rect.bottom > viewport.top && rect.top < viewport.bottom) { + const previous = state.visibleSubtrees.get(key) + if (previous && previous !== element && key.startsWith(`${textPartID}:`)) + state.visibleSubtreeReplacements += 1 + visibleSubtrees.set(key, element) + } + }) + state.visibleSubtrees.forEach((element, key) => { + const current = subtrees.get(key) + if (key.startsWith(`${textPartID}:`) && !current?.rendered) { + const markdown = part.querySelector('[data-component="markdown"]') + state.visibleSubtreeDropouts.push( + `${key}:projection=${markdown?.dataset.markdownProjectionLength}/${markdown?.dataset.markdownProjectionBlocks}:result=${markdown?.dataset.markdownResultLength}/${markdown?.dataset.markdownResultBlocks}:applied=${markdown?.dataset.markdownAppliedBlocks}:dom=${markdown?.children.length}`, + ) + } + if (element.matches('[data-component="file"]')) { + const hadLines = element.hasAttribute("data-profiler-had-lines") + const hasLines = element.shadowRoot?.querySelector("[data-line]") != null + if (hasLines) element.setAttribute("data-profiler-had-lines", "") + if (hadLines && !hasLines) state.visibleSubtreeDropouts.push(`${key}:shadow-lines`) + } + }) + state.visibleSubtrees = visibleSubtrees + } + if (profileVisual && duration > 33.34) { + const livePart = currentPart() + const content = livePart?.textContent ?? "" + const complete = content.includes("benchmark-complete") + const index = complete + ? finalIndex + : Number(content.match(new RegExp(markerPattern, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1) + state.slowFrames.push({ + duration, + index, + phase: complete + ? "complete" + : index >= 0 && index % fragmentCount === 0 + ? "boundary" + : index >= 0 + ? "stream" + : "unknown", + tokenSpans: livePart?.querySelectorAll(".shiki span").length ?? 0, + blocks: livePart?.querySelectorAll("[data-markdown-block]").length ?? 0, + codeBlocks: livePart?.querySelectorAll('[data-component="markdown-code"]').length ?? 0, + height: livePart?.getBoundingClientRect().height ?? 0, + distance: root.scrollHeight - root.clientHeight - root.scrollTop, + }) + } + requestAnimationFrame(sample) + }, 0) + } + state.start = () => { + state.started = performance.now() + state.previous = state.started + state.running = true + requestAnimationFrame(sample) + } + }, + { ...options, markerPattern: STREAM_MARKER_PATTERN, fragmentCount: STREAM_FRAGMENT_COUNT }, + ) +} + +export function startTimelineStreamProbe(page: Page) { + return page.evaluate(() => { + const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark + if (!state) throw new Error("missing streaming benchmark state") + state.start() + }) +} + +type LayoutShiftEntry = PerformanceEntry & { value: number; hadRecentInput?: boolean } + +export function layoutShiftValue( + entry: Pick, + start: number, +) { + if (entry.startTime < start || entry.hadRecentInput) return + return entry.value +} + +export function removeVisibleRow(visible: Set, row: T) { + return visible.delete(row) +} + +export function streamProgress(content: string) { + const index = Number(content.match(new RegExp(STREAM_MARKER_PATTERN, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1) + return { + index, + phase: content.includes("benchmark-complete") + ? ("complete" as const) + : index >= 0 && index % STREAM_FRAGMENT_COUNT === 0 + ? ("boundary" as const) + : index >= 0 + ? ("stream" as const) + : ("unknown" as const), + } +} + +export async function collectTimelineStreamMetrics( + page: Page, + options: { textPartID: string; finalIndex: number; navigations: string[] }, +) { + return page.evaluate(({ textPartID, finalIndex, navigations }) => { + const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark + if (!state) throw new Error(`missing streaming benchmark state after navigation: ${JSON.stringify(navigations)}`) + state.ended = performance.now() + state.cleanup() + state.running = false + const part = document.querySelector(`[data-timeline-part-id="${textPartID}"]`) + const row = part?.closest("[data-timeline-row]") + const markdown = part?.querySelector('[data-component="markdown"]') + const sorted = state.frames.slice().sort((a, b) => a - b) + const duration = state.frames.reduce((sum, value) => sum + value, 0) + const longestSlowStreak = state.frames.reduce( + (result, value) => { + const current = value > 33.34 ? result.current + 1 : 0 + return { current, longest: Math.max(result.longest, current) } + }, + { current: 0, longest: 0 }, + ).longest + const busyStart = state.applied.at(0)?.at + const completion = state.applied.find((value) => value.index === finalIndex) + const busyEnd = completion?.at + const busyFrames = + busyStart === undefined || busyEnd === undefined + ? [] + : state.frames.filter((_, index) => state.frameAt[index]! >= busyStart && state.frameAt[index]! <= busyEnd) + const busySorted = busyFrames.slice().sort((a, b) => a - b) + const busyDuration = busyFrames.reduce((sum, value) => sum + value, 0) + const completionObservedMs = (completion?.at ?? NaN) - state.started + const visual = state.profileVisual + ? { + layoutShiftValueSum: state.layoutShifts.reduce((sum, value) => sum + value, 0), + maxLayoutShiftValue: Math.max(0, ...state.layoutShifts), + visibleMounts: state.visibleMounts, + visibleUnmounts: state.visibleUnmounts, + visibleSubtreeMounts: state.visibleSubtreeMounts, + visibleSubtreeUnmounts: [...new Set(state.visibleSubtreeUnmounts)], + visibleSubtreeReplacements: state.visibleSubtreeReplacements, + visibleSubtreeDropouts: [...new Set(state.visibleSubtreeDropouts)], + maxOverlapPx: state.maxOverlap, + maxGapPx: state.maxGap, + maxPartTopMovementPx: state.maxPartTopMovement, + slowestRafGaps: state.slowFrames + .sort((a, b) => b.duration - a.duration) + .slice(0, 20) + .map((frame) => ({ + durationMs: frame.duration, + index: frame.index, + phase: frame.phase, + tokenSpans: frame.tokenSpans, + blocks: frame.blocks, + codeBlocks: frame.codeBlocks, + heightPx: frame.height, + distancePx: frame.distance, + })), + slowRafGapPhases: Object.fromEntries( + ["stream", "boundary", "complete", "unknown"].map((phase) => { + const frames = state.slowFrames.filter((frame) => frame.phase === phase) + return [ + phase, + { + count: frames.length, + totalMs: frames.reduce((sum, frame) => sum + frame.duration, 0), + maxMs: Math.max(0, ...frames.map((frame) => frame.duration)), + }, + ] + }), + ), + scroll: state.scroll, + } + : null + const geometry = state.minimal + ? null + : { + maxDistancePx: Math.max(0, ...state.geometry.map((sample) => sample.distance)), + finalDistancePx: state.geometry.at(-1)?.distance ?? 0, + final: state.geometry.at(-1), + distanceTransitionsPx: state.geometry + .map((sample) => Math.round(sample.distance)) + .filter((value, index, values) => index === 0 || value !== values[index - 1]), + bottomDriftTransitions: state.geometry.slice(1).filter((value, index) => { + const previous = state.geometry[index]?.distance ?? 0 + return previous <= 1 && value.distance > 1 + }).length, + blankSamples: state.blanks, + } + return { + capabilities: { visual: state.profileVisual, geometry: !state.minimal }, + completionObservedMs, + deltasPerSecond: Number.isFinite(completionObservedMs) ? finalIndex / (completionObservedMs / 1_000) : null, + rafGapSamples: state.frames.length, + rafCallbackRate: duration ? (state.frames.length * 1000) / duration : 0, + observedProgressWindowRafCallbackRate: busyDuration ? (busyFrames.length * 1000) / busyDuration : null, + observedProgressWindowRafGapP95Ms: busySorted[Math.floor(busySorted.length * 0.95)] ?? null, + observedProgressWindowRafGaps: busyFrames.length, + maxObservedProgressIndex: Math.max(-1, ...state.applied.map((value) => value.index)), + observedProgressTransitions: state.applied.length, + rafGapP50Ms: sorted[Math.floor(sorted.length * 0.5)] ?? 0, + rafGapP95Ms: sorted[Math.floor(sorted.length * 0.95)] ?? 0, + rafGapP99Ms: sorted[Math.floor(sorted.length * 0.99)] ?? 0, + maxRafGapMs: sorted.at(-1) ?? 0, + rafGapsOver33Ms: state.frames.filter((value) => value > 33.34).length, + rafGapsOver50Ms: state.frames.filter((value) => value > 50).length, + missedFrameBudgetEquivalents: state.frames.reduce( + (sum, value) => sum + Math.max(0, Math.round(value / 16.67) - 1), + 0, + ), + longestRafGapOver33MsStreak: longestSlowStreak, + longTaskCount: state.longTasks.length, + longTaskTimeMs: state.longTasks.reduce((sum, value) => sum + value, 0), + visual, + geometry, + rowReplaced: row !== state.row, + markdownReplaced: markdown !== state.markdown, + domTextCharacters: part?.textContent?.length ?? 0, + } + }, options) +} diff --git a/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts b/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts new file mode 100644 index 000000000000..529081a1d9e0 --- /dev/null +++ b/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts @@ -0,0 +1,369 @@ +const words = [ + "alpha", + "bravo", + "charlie", + "delta", + "echo", + "foxtrot", + "golf", + "hotel", + "india", + "juliet", + "kilo", + "lima", + "metro", + "nova", + "orbit", + "pixel", + "quartz", + "river", + "signal", + "vector", +] + +const sourceID = "ses_smoke_source" +const targetID = "ses_smoke_target" +const childID = "ses_smoke_child" +const directory = "C:/OpenCode/SmokeProject" +const projectID = "proj_smoke_timeline" +const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } + +type MessageInfo = Record & { id: string; role: "user" | "assistant" } +type MessagePart = Record & { id: string; type: string; text?: string; tool?: string } +type Message = { info: MessageInfo; parts: MessagePart[] } + +function lorem(seed: number, length: number) { + let out = "" + let i = seed + while (out.length < length) { + const word = words[i % words.length] + out += (out ? " " : "") + word + if (i % 17 === 0) out += ".\n\n" + i += 7 + } + return out.slice(0, length) +} + +function id(prefix: string, value: number) { + return `${prefix}_smoke_${String(value).padStart(4, "0")}` +} + +function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message { + const messageID = id("msg_user", index) + return { + info: { + id: messageID, + sessionID, + role: "user", + time: { created: 1700000000000 + index * 10_000 }, + summary: { diffs }, + agent: "build", + model, + }, + parts: [ + { + id: id("prt_user_text", index), + sessionID, + messageID, + type: "text", + text: lorem(index, textLength), + }, + ], + } +} + +function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message { + const messageID = id("msg_assistant", index) + return { + info: { + id: messageID, + sessionID, + role: "assistant", + time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 }, + parentID, + modelID: model.modelID, + providerID: model.providerID, + mode: "build", + agent: "build", + path: { cwd: directory, root: directory }, + cost: 0.01, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + variant: "max", + finish: "stop", + }, + parts: parts.map((part) => ({ + ...part, + sessionID, + messageID, + })), + } +} + +function textPart(index: number, partIndex: number, length: number): MessagePart { + const prose = lorem(index * 13 + partIndex, length) + const text = + index % 12 === 0 + ? `${prose}\n\n\`\`\`ts\n${code(index, 80)}\n\`\`\`` + : index % 5 === 0 + ? `${prose}\n\n\`\`\`ts\nexport const value = "${lorem(index, 220)}"\n\`\`\`` + : index % 7 === 0 + ? `${prose}\n\nThe wrapped inline value is \`${lorem(index, 180)}\`.` + : prose + return { id: id(`prt_text_${partIndex}`, index), type: "text", text } +} + +function reasoningPart(index: number, partIndex: number, length: number): MessagePart { + return { + id: id(`prt_reasoning_${partIndex}`, index), + type: "reasoning", + text: lorem(index * 19 + partIndex, length), + time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 500 }, + } +} + +function toolPart( + index: number, + partIndex: number, + tool: string, + input: Record, + outputLength = 160, + metadataOverride?: Record, +): MessagePart { + const metadata = + metadataOverride ?? + (tool === "apply_patch" + ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] } + : tool === "edit" || tool === "write" + ? { + filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index), + diff: patch(index, outputLength), + preview: patch(index + 1, 420), + } + : tool === "question" + ? { answers: [["Proceed"], ["Keep sample output"]] } + : {}) + return { + id: id(`prt_tool_${tool}_${partIndex}`, index), + type: "tool", + callID: id("call", index * 10 + partIndex), + tool, + state: { + status: "completed", + input, + output: lorem(index * 23 + partIndex, outputLength), + title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed", + metadata, + time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 }, + }, + } +} + +function patchFile(seed: number, type: "add" | "update" | "delete") { + return { + filePath: `src/generated/patch-${seed}.ts`, + relativePath: `src/generated/patch-${seed}.ts`, + type, + additions: (seed % 7) + 1, + deletions: type === "add" ? 0 : seed % 4, + patch: patch(seed, 520), + before: type === "add" ? undefined : code(seed, 18), + after: type === "delete" ? undefined : code(seed + 1, 24), + } +} + +function fileDiff(file: string, seed: number) { + const lines = seed % 12 === 0 ? 300 : seed % 8 === 0 ? 2 : 38 + const before = code(seed, lines, seed % 10 === 0 ? 280 : 32) + const after = + lines === 2 + ? before.replace("value1", "updatedValue1") + : lines === 300 + ? code(seed + 1, lines, seed % 10 === 0 ? 280 : 32) + : before.replace("value4", "updatedValue4").replace("value20", "updatedValue20") + return { + file, + additions: lines === 300 ? 300 : lines === 2 ? 1 : 2, + deletions: lines === 300 ? 300 : lines === 2 ? 1 : 2, + before, + after, + } +} + +function patch(seed: number, length: number) { + return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}` +} + +function code(seed: number, lines: number, width = 32) { + return Array.from( + { length: lines }, + (_, index) => `export const value${index} = "${lorem(seed + index, width)}"`, + ).join("\n") +} + +function turn(index: number): Message[] { + const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : [] + const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff) + const parts = [ + ...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []), + ...(index % 3 === 0 + ? [ + toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220), + toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140), + toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180), + toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120), + ] + : []), + textPart(index, 2, 160 + (index % 6) * 90), + ...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []), + ...(index % 6 === 0 + ? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)] + : []), + ...(index % 8 === 0 + ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] + : []), + ...(index % 7 === 0 + ? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)] + : []), + ...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []), + ...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []), + ...(index % 13 === 0 + ? [ + toolPart( + index, + 11, + "question", + { questions: [{ question: "Use generated fixture?" }, { question: "Keep same row shape?" }] }, + 120, + ), + ] + : []), + ...(index % 17 === 0 + ? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)] + : []), + ] + return [user, assistantMessage(targetID, index, user.info.id, parts)] +} + +const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat() +const sourceMessages = Array.from({ length: 12 }, (_, index) => [ + userMessage(sourceID, index + 1000, 120), + assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [ + textPart(index + 1000, 0, 240), + ...(index === 11 + ? [ + toolPart( + index + 1000, + 1, + "task", + { description: "Inspect child navigation", subagent_type: "explore" }, + 160, + { sessionId: childID }, + ), + ] + : []), + ]), +]).flat() +const childMessages = Array.from({ length: 4 }, (_, index) => [ + userMessage(childID, index + 2000, 120), + assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]), +]).flat() + +function renderable(part: MessagePart) { + if (part.type === "tool" && part.tool === "todowrite") return false + if (part.type === "text") return !!part.text.trim() + if (part.type === "reasoning") return !!part.text.trim() + return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch" +} + +function orderedParts(message: Message) { + return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)) +} + +export const fixture = { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "smoke-project", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "claude-opus-4-6" }, + }, + sessions: [ + { + id: sourceID, + slug: "source", + projectID, + directory, + title: "Uncommitted changes inquiry", + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + { + id: targetID, + slug: "target", + projectID, + directory, + title: "Example Game: sample jump movement & sample physics analysis", + version: "dev", + time: { created: 1700000001000, updated: 1700000001000 }, + }, + { + id: childID, + parentID: sourceID, + slug: "child", + projectID, + directory, + title: "Inspect child navigation", + version: "dev", + time: { created: 1700000002000, updated: 1700000002000 }, + }, + ], + sourceID, + targetID, + childID, + messages: { [sourceID]: sourceMessages, [targetID]: targetMessages, [childID]: childMessages }, + expected: { + sourceTitle: "Uncommitted changes inquiry", + targetTitle: "Example Game: sample jump movement & sample physics analysis", + childTitle: "Inspect child navigation", + sourceMessageIDs: sourceMessages + .filter((message) => message.info.role === "user") + .map((message) => message.info.id), + targetMessageIDs: targetMessages + .filter((message) => message.info.role === "user") + .map((message) => message.info.id), + childMessageIDs: childMessages.filter((message) => message.info.role === "user").map((message) => message.info.id), + targetPartIDs: targetMessages.flatMap((message) => + orderedParts(message) + .filter(renderable) + .map((part) => part.id), + ), + }, +} + +export function pageMessages(sessionID: string, limit: number, before?: string) { + const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] + const end = before + ? Math.max( + 0, + messages.findIndex((message) => message.info.id === before), + ) + : messages.length + const start = Math.max(0, end - limit) + return { + items: messages.slice(start, end), + cursor: start > 0 ? messages[start]!.info.id : undefined, + } +} diff --git a/packages/app/e2e/performance/timeline/timeline-test-helpers.ts b/packages/app/e2e/performance/timeline/timeline-test-helpers.ts new file mode 100644 index 000000000000..5028373c55f6 --- /dev/null +++ b/packages/app/e2e/performance/timeline/timeline-test-helpers.ts @@ -0,0 +1,134 @@ +import type { Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { mockOpenCodeServer } from "../../utils/mock-server" +import { fixture, pageMessages } from "./session-timeline-stress.fixture" + +export async function installTimelineSettings(page: Page) { + await page.addInitScript(() => { + localStorage.setItem( + "settings.v3", + JSON.stringify({ + general: { + newLayoutDesigns: true, + editToolPartsExpanded: true, + shellToolPartsExpanded: true, + showReasoningSummaries: true, + }, + }), + ) + }) +} + +export function mockStressTimeline( + page: Page, + input?: { + onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void + vcsDiff?: unknown[] + }, +) { + return mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + onMessages: input?.onMessages, + vcsDiff: input?.vcsDiff, + }) +} + +export async function installStressSessionTabs(page: Page, input?: { draftID?: string; sessionIDs?: string[] }) { + const server = stressServer() + await page.addInitScript( + ({ directory, sessionIDs, dirBase64, server, draftID }) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([ + ...sessionIDs.map((sessionId) => ({ + type: "session", + server, + dirBase64, + sessionId, + })), + ...(draftID ? [{ type: "draft", draftID, server, directory }] : []), + ]), + ) + }, + { + directory: fixture.directory, + sessionIDs: input?.sessionIDs ?? [fixture.sourceID, fixture.targetID], + dirBase64: base64Encode(fixture.directory), + server, + draftID: input?.draftID, + }, + ) +} + +export function stressSessionHref(sessionID: string) { + return `/server/${base64Encode(stressServer())}/session/${sessionID}` +} + +export function stressDraftHref(draftID: string) { + return `/new-session?draftId=${encodeURIComponent(draftID)}` +} + +function stressServer() { + return `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` +} + +export function createReviewDiffs() { + return Array.from({ length: Number(process.env.REVIEW_PANE_DIFF_COUNT ?? 72) }, (_, index) => { + const lines = index % 3 === 0 ? 300 : index % 3 === 1 ? 120 : 38 + const file = `src/review/generated-${String(index).padStart(3, "0")}.ts` + const before = reviewSource(index, lines) + const after = before + .replace(`value_${index}_4`, `updated_${index}_4`) + .replace( + `value_${index}_${Math.max(8, Math.floor(lines / 2))}`, + `updated_${index}_${Math.max(8, Math.floor(lines / 2))}`, + ) + .replace(`value_${index}_${lines - 4}`, `updated_${index}_${lines - 4}`) + return { + file, + patch: reviewPatch(file, before, after), + additions: 3, + deletions: 3, + status: "modified" as const, + } + }) +} + +function reviewSource(seed: number, lines: number) { + return Array.from( + { length: lines }, + (_, index) => `export const value_${seed}_${index} = "${reviewWords(seed + index, index % 5 === 0 ? 180 : 42)}"`, + ).join("\n") +} + +function reviewPatch(file: string, before: string, after: string) { + const beforeLines = before.split("\n") + const afterLines = after.split("\n") + return [ + `diff --git a/${file} b/${file}`, + `--- a/${file}`, + `+++ b/${file}`, + `@@ -1,${beforeLines.length} +1,${afterLines.length} @@`, + ...beforeLines.flatMap((line, index) => { + const next = afterLines[index]! + if (line === next) return [` ${line}`] + return [`-${line}`, `+${next}`] + }), + ].join("\n") +} + +function reviewWords(seed: number, length: number) { + const words = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", "juliet"] + return Array.from({ length: Math.ceil(length / 7) }, (_, index) => words[(seed + index * 3) % words.length]).join(" ") +} diff --git a/packages/app/e2e/performance/unit/chrome-trace-write.test.ts b/packages/app/e2e/performance/unit/chrome-trace-write.test.ts new file mode 100644 index 000000000000..456020ff30d7 --- /dev/null +++ b/packages/app/e2e/performance/unit/chrome-trace-write.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import path from "node:path" +import os from "node:os" +import { prepareChromeTrace } from "../chrome-trace" + +test("creates the configured trace directory", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "opencode-trace-")) + try { + const file = await prepareChromeTrace(path.join(root, "nested", "traces"), "session/tab", false, "test") + expect(file).toEndWith("-session-tab-458ed9e3-test.json") + } finally { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/packages/app/e2e/performance/unit/first-navigation-metrics.test.ts b/packages/app/e2e/performance/unit/first-navigation-metrics.test.ts new file mode 100644 index 000000000000..c0e5474d6e91 --- /dev/null +++ b/packages/app/e2e/performance/unit/first-navigation-metrics.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from "bun:test" +import { summarizeFirstNavigation } from "../timeline/first-navigation-metrics" + +test("reports blank frames before first destination and stable paint", () => { + expect( + summarizeFirstNavigation([ + { observedAtMs: 16, source: true, destination: false, content: true }, + { observedAtMs: 32, source: false, destination: false, content: false }, + { observedAtMs: 48, source: false, destination: true, content: true }, + { observedAtMs: 64, source: false, destination: true, content: true }, + { observedAtMs: 80, source: false, destination: true, content: true }, + ]), + ).toEqual({ + samples: 5, + firstDestinationObservedMs: 48, + stableDestinationObservedMs: 80, + sourceSamples: 1, + blankSamples: 1, + unknownSamples: 0, + destinationSamples: 3, + }) +}) + +test("does not report stability for interrupted destination frames", () => { + expect( + summarizeFirstNavigation([ + { observedAtMs: 16, source: false, destination: true, content: true }, + { observedAtMs: 32, source: false, destination: false, content: true }, + { observedAtMs: 48, source: false, destination: true, content: true }, + ]).stableDestinationObservedMs, + ).toBeNull() +}) diff --git a/packages/app/e2e/performance/unit/mock-server.test.ts b/packages/app/e2e/performance/unit/mock-server.test.ts new file mode 100644 index 000000000000..83308c0a866b --- /dev/null +++ b/packages/app/e2e/performance/unit/mock-server.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test" +import type { Page, Route } from "@playwright/test" +import { mockOpenCodeServer } from "../../utils/mock-server" + +test("applies message latency after a list response gate is released", async () => { + const events: string[] = [] + const gate = Promise.withResolvers() + let handler: ((route: Route) => Promise) | undefined + const page = { + route: (_url: string, callback: (route: Route) => Promise) => { + handler = callback + return Promise.resolve() + }, + } as unknown as Page + await mockOpenCodeServer(page, { + provider: {}, + directory: "C:/OpenCode", + project: {}, + sessions: [{ id: "session" }], + messageDelay: 25, + beforeMessagesResponse: () => { + events.push("before") + return gate.promise + }, + onMessages: (request) => events.push(request.phase), + pageMessages: () => { + events.push("page") + return { items: [] } + }, + }) + + const response = handler!({ + request: () => ({ url: () => "http://127.0.0.1:4096/session/session/message" }), + fulfill: () => { + events.push("fulfill") + return Promise.resolve() + }, + } as unknown as Route) + expect(events).toEqual(["start", "before"]) + + const released = performance.now() + gate.resolve() + await response + expect(performance.now() - released).toBeGreaterThanOrEqual(20) + expect(events).toEqual(["start", "before", "page", "end", "fulfill"]) +}) diff --git a/packages/app/e2e/performance/unit/navigation-milestones.test.ts b/packages/app/e2e/performance/unit/navigation-milestones.test.ts new file mode 100644 index 000000000000..e22be67063d2 --- /dev/null +++ b/packages/app/e2e/performance/unit/navigation-milestones.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test" +import { summarizeNavigationMilestones } from "../timeline/navigation-milestones" + +test("reports first and stable paint for each navigation milestone", () => { + expect( + summarizeNavigationMilestones([ + { observedAtMs: 16, milestones: { content: false, tab: false } }, + { observedAtMs: 32, milestones: { content: true, tab: false } }, + { observedAtMs: 48, milestones: { content: true, tab: true } }, + { observedAtMs: 64, milestones: { content: true, tab: true } }, + { observedAtMs: 80, milestones: { content: true, tab: true } }, + ]), + ).toEqual({ + samples: 5, + milestones: { + content: { firstObservedMs: 32, stableObservedMs: 64 }, + tab: { firstObservedMs: 48, stableObservedMs: 80 }, + }, + all: { firstObservedMs: 48, stableObservedMs: 80 }, + }) +}) + +test("reports missing stability when a milestone appears in the final samples", () => { + expect( + summarizeNavigationMilestones([ + { observedAtMs: 16, milestones: { content: false } }, + { observedAtMs: 32, milestones: { content: true } }, + ]), + ).toEqual({ + samples: 2, + milestones: { content: { firstObservedMs: 32, stableObservedMs: null } }, + all: { firstObservedMs: 32, stableObservedMs: null }, + }) +}) diff --git a/packages/app/e2e/performance/unit/session-tab-repaint-probe.test.ts b/packages/app/e2e/performance/unit/session-tab-repaint-probe.test.ts new file mode 100644 index 000000000000..5d20c03a00f4 --- /dev/null +++ b/packages/app/e2e/performance/unit/session-tab-repaint-probe.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test" +import { compressCachedRepaintTrace, layoutShiftSample } from "../timeline/session-tab-repaint-probe" + +test("compresses repeated repaint states without losing frame samples", () => { + const state = { + root: 1, + scrollTop: 10, + scrollHeight: 20, + bottomErrorPx: 0, + last: true, + rows: [{ key: "row", node: 2, top: 0, bottom: 10 }], + mounted: 1, + center: "content", + } + const trace = { + timeOriginEpochMs: 1_000, + startedAtPerformanceMs: 100, + samples: [ + { observedAtMs: 16, ...state, destination: ["target"], source: [] }, + { observedAtMs: 32, ...state, destination: ["target"], source: [] }, + { observedAtMs: 48, ...state, scrollTop: 11, destination: ["target"], source: [] }, + ], + mutations: [{ observedAtMs: 20, changed: [{ type: "add", node: 2 }] }], + shifts: [{ occurredAtMs: 24, value: 0.1 }], + windowMs: 1_000, + running: false, + stop() {}, + } + const compressed = compressCachedRepaintTrace(trace) + const samples = compressed.samples.flatMap((group) => + group.observedAtMs.map((observedAtMs) => ({ observedAtMs, ...group.state })), + ) + + expect(samples).toEqual(trace.samples) + expect(compressed.mutations).toEqual(trace.mutations) + expect(compressed.shifts).toEqual(trace.shifts) +}) + +test("records layout shifts at occurrence time within the probe window", () => { + expect(layoutShiftSample({ startTime: 99, value: 0.1 }, 100)).toBeUndefined() + expect(layoutShiftSample({ startTime: 124, value: 0.2 }, 100)).toEqual({ occurredAtMs: 24, value: 0.2 }) +}) diff --git a/packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts b/packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts new file mode 100644 index 000000000000..4b824d9dfb0d --- /dev/null +++ b/packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from "bun:test" +import { classifySessionSwitch } from "../timeline/session-tab-switch-metrics" + +test("counts source and blank samples before the destination is observed", () => { + const result = classifySessionSwitch([ + { observedAtMs: 16, destination: [], source: ["source"], hasVisibleRows: true, last: false }, + { observedAtMs: 32, destination: [], source: [], hasVisibleRows: false, last: false }, + { observedAtMs: 48, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 }, + { observedAtMs: 64, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 }, + { observedAtMs: 80, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 }, + ]) + + expect(result.blankSamples).toBe(1) + expect(result.sourceSamples).toBe(1) + expect(result.unknownSamples).toBe(0) + expect(result.firstDestinationObservedMs).toBe(48) + expect(result.stableObservedMs).toBe(80) +}) + +test("does not classify mixed source and destination content as correct", () => { + const result = classifySessionSwitch([ + { + observedAtMs: 16, + destination: ["destination"], + source: ["source"], + hasVisibleRows: true, + last: true, + bottomErrorPx: 0, + }, + { observedAtMs: 32, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 }, + { observedAtMs: 48, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 }, + { observedAtMs: 64, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 }, + ]) + + expect(result.firstCorrectObservedMs).toBe(32) + expect(result.stableObservedMs).toBe(64) +}) + +test("reports missing correctness without throwing", () => { + const result = classifySessionSwitch([ + { + observedAtMs: 16, + destination: ["destination"], + source: ["source"], + hasVisibleRows: true, + last: true, + bottomErrorPx: 0, + }, + ]) + + expect(result.firstDestinationObservedMs).toBe(16) + expect(result.firstCorrectObservedMs).toBeNull() + expect(result.stableObservedMs).toBeNull() +}) + +test("requires an explicitly tracked part to be visible", () => { + const result = classifySessionSwitch([ + { + observedAtMs: 16, + destination: ["destination"], + source: [], + hasVisibleRows: true, + last: true, + requiredPartVisible: false, + bottomErrorPx: 0, + }, + ]) + + expect(result.firstCorrectObservedMs).toBeNull() +}) + +test("can measure content correctness without requiring a bottom anchor", () => { + const result = classifySessionSwitch([ + { + observedAtMs: 16, + destination: ["destination"], + source: [], + hasVisibleRows: true, + last: true, + requiredPartVisible: true, + bottomAnchorRequired: false, + }, + ]) + + expect(result.firstCorrectObservedMs).toBe(16) +}) diff --git a/packages/app/e2e/performance/unit/session-tab-switch-probe.test.ts b/packages/app/e2e/performance/unit/session-tab-switch-probe.test.ts new file mode 100644 index 000000000000..d5d7f09a69ef --- /dev/null +++ b/packages/app/e2e/performance/unit/session-tab-switch-probe.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test" +import type { Page } from "@playwright/test" +import { measureSessionSwitch } from "../timeline/session-tab-switch-probe" + +function testPage(waitFailure?: Error) { + const stops: unknown[] = [] + const page = { + evaluate: async (_callback: unknown, input?: unknown) => { + if (input) return + stops.push(undefined) + }, + waitForFunction: async () => { + if (waitFailure) throw waitFailure + }, + } as unknown as Page + return { page, stops } +} + +function input(run: () => Promise) { + return { + destinationIDs: ["destination"], + sourceIDs: ["source"], + lastID: "destination", + href: "/session/destination", + switch: run, + } +} + +test("stops sampling when the session switch fails", async () => { + const failure = new Error("switch failed") + const context = testPage() + + await expect( + measureSessionSwitch( + context.page, + input(async () => Promise.reject(failure)), + ), + ).rejects.toBe(failure) + + expect(context.stops).toHaveLength(1) +}) + +test("stops sampling when the stable wait fails", async () => { + const failure = new Error("stable wait failed") + const context = testPage(failure) + + await expect( + measureSessionSwitch( + context.page, + input(async () => {}), + ), + ).rejects.toBe(failure) + + expect(context.stops).toHaveLength(1) +}) diff --git a/packages/app/e2e/performance/unit/session-timeline-stream-probe.test.ts b/packages/app/e2e/performance/unit/session-timeline-stream-probe.test.ts new file mode 100644 index 000000000000..f8fca1adb733 --- /dev/null +++ b/packages/app/e2e/performance/unit/session-timeline-stream-probe.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test" +import { streamChunk } from "../timeline/session-timeline-benchmark.fixture" +import { streamProgress } from "../timeline/session-timeline-stream-probe" + +test("classifies emitted stream markers using the fixture cycle", () => { + expect(streamProgress("before stream-17 after stream-18")).toEqual({ index: 18, phase: "boundary" }) + expect(streamProgress("before stream-18 after stream-19")).toEqual({ index: 19, phase: "stream" }) + expect(streamProgress("benchmark-complete stream-36")).toEqual({ index: 36, phase: "complete" }) + expect(streamProgress("no marker")).toEqual({ index: -1, phase: "unknown" }) +}) + +test("emits progress markers at fixture boundaries", () => { + expect(streamProgress(streamChunk(18, 160))).toEqual({ index: 18, phase: "boundary" }) +}) diff --git a/packages/app/e2e/performance/unit/session-timeline-visual-tracking.test.ts b/packages/app/e2e/performance/unit/session-timeline-visual-tracking.test.ts new file mode 100644 index 000000000000..c0215c5cb6ca --- /dev/null +++ b/packages/app/e2e/performance/unit/session-timeline-visual-tracking.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "bun:test" +import { layoutShiftValue, removeVisibleRow } from "../timeline/session-timeline-stream-probe" + +test("excludes layout shifts before the probe window and recent input", () => { + expect(layoutShiftValue({ startTime: 9, value: 0.1 }, 10)).toBeUndefined() + expect(layoutShiftValue({ startTime: 10, value: 0.2, hadRecentInput: true }, 10)).toBeUndefined() + expect(layoutShiftValue({ startTime: 11, value: 0.3 }, 10)).toBe(0.3) +}) + +test("classifies removed rows from their last painted visibility", () => { + const row = {} + const visible = new Set([row]) + + expect(removeVisibleRow(visible, row)).toBe(true) + expect(removeVisibleRow(visible, row)).toBe(false) +}) diff --git a/packages/app/e2e/performance/unit/timeline-test-helpers.test.ts b/packages/app/e2e/performance/unit/timeline-test-helpers.test.ts new file mode 100644 index 000000000000..98b661d91bba --- /dev/null +++ b/packages/app/e2e/performance/unit/timeline-test-helpers.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from "bun:test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { fixture } from "../timeline/session-timeline-stress.fixture" +import { stressSessionHref } from "../timeline/timeline-test-helpers" + +test("builds stress session links for the benchmark server", () => { + expect(stressSessionHref(fixture.sourceID)).toBe( + `/server/${base64Encode("http://127.0.0.1:4096")}/session/${fixture.sourceID}`, + ) +}) diff --git a/packages/app/e2e/performance/unit/visual-stability.test.ts b/packages/app/e2e/performance/unit/visual-stability.test.ts new file mode 100644 index 000000000000..2fc3be316660 --- /dev/null +++ b/packages/app/e2e/performance/unit/visual-stability.test.ts @@ -0,0 +1,392 @@ +import { expect, test } from "bun:test" +import { + analyzeVisualStability, + analyzeVisualStabilityByMarker, + type VisualStabilityTrace, +} from "../../utils/visual-stability" +import { analyzeVisualObservations } from "../../utils/visual-stability/analyzer" +import { legacyVisualPlan, visualPlan, type VisualInvariant } from "../../utils/visual-stability/invariant" +import { defineVisualRegions, mapVisualRegions } from "../../utils/visual-stability/regions" + +function trace(samples: VisualStabilityTrace["samples"]): VisualStabilityTrace { + return { markers: [], samples } +} + +test("accepts continuous visible motion", () => { + expect( + analyzeVisualStability( + trace([ + frame(0, region({ width: 80, bottom: 40 }), region({ top: 40, bottom: 60 })), + frame(16, region({ width: 75, bottom: 45 }), region({ top: 45, bottom: 65 })), + frame(32, region({ width: 70, bottom: 50 }), region({ top: 50, bottom: 70 })), + ]), + { flow: ["changing", "following"] }, + ), + ).toEqual([]) +}) + +test("reports repeated geometry reversals", () => { + const issues = analyzeVisualStability( + trace([ + frame(0, region({ width: 80 })), + frame(16, region({ width: 60 })), + frame(32, region({ width: 78 })), + frame(48, region({ width: 62 })), + ]), + ) + + expect(issues.some((issue) => issue.includes("changing width reversed 2 times"))).toBe(true) +}) + +test("reports visible blanking, label reversal, and overlap", () => { + const issues = analyzeVisualStability( + trace([ + frame(0, region({ label: "Exploring", opacity: 1, bottom: 40 }), region({ top: 40, bottom: 60 })), + frame(16, region({ label: "Explored", opacity: 0.2, bottom: 50 }), region({ top: 49, bottom: 69 })), + frame(32, region({ label: "Exploring", opacity: 1, bottom: 50 }), region({ top: 50, bottom: 70 })), + ]), + { flow: ["changing", "following"] }, + ) + + expect(issues.some((issue) => issue.includes("opacity fell to 0.2"))).toBe(true) + expect(issues.some((issue) => issue.includes("label reverted"))).toBe(true) + expect(issues.some((issue) => issue.includes("overlapped following by 1px"))).toBe(true) +}) + +test("reports duplicate regions and unexpected remounts", () => { + const issues = analyzeVisualStability( + trace([frame(0, region({ node: 1 })), frame(16, region({ node: 2, count: 2 })), frame(32, region({ node: 2 }))]), + { stable: ["changing"], unique: ["changing"] }, + ) + + expect(issues.some((issue) => issue.includes("changing appeared 2 times"))).toBe(true) + expect(issues.some((issue) => issue.includes("changing remounted"))).toBe(true) +}) + +test("reports bottom anchor loss but permits movement while scrolled away", () => { + const anchored = analyzeVisualStability( + trace([ + { at: 0, regions: { changing: region() }, viewport: viewport(0) }, + { at: 16, regions: { changing: region() }, viewport: viewport(24) }, + ]), + { preserveBottomAnchor: true }, + ) + const away = analyzeVisualStability( + trace([ + { at: 0, regions: { changing: region() }, viewport: viewport(80) }, + { at: 16, regions: { changing: region() }, viewport: viewport(104) }, + ]), + { preserveBottomAnchor: true }, + ) + + expect(anchored.some((issue) => issue.includes("bottom anchor moved to 24px"))).toBe(true) + expect(away).toEqual([]) +}) + +test("reports up down up movement while preserving a bottom anchor", () => { + const issues = analyzeVisualStability( + trace([ + { at: 0, regions: { changing: region({ top: 200, bottom: 240 }) }, viewport: viewport(0) }, + { at: 16, regions: { changing: region({ top: 180, bottom: 220 }) }, viewport: viewport(0) }, + { at: 32, regions: { changing: region({ top: 196, bottom: 236 }) }, viewport: viewport(0) }, + { at: 48, regions: { changing: region({ top: 176, bottom: 216 }) }, viewport: viewport(0) }, + ]), + { preserveBottomAnchor: true, maxPositionReversals: 0 }, + ) + + expect(issues.some((issue) => issue.includes("changing top reversed 2 times"))).toBe(true) + expect(issues.some((issue) => issue.includes("changing bottom reversed 2 times"))).toBe(true) +}) + +test("accepts monotonic upward movement while preserving a bottom anchor", () => { + expect( + analyzeVisualStability( + trace([ + { at: 0, regions: { changing: region({ top: 200, bottom: 240 }) }, viewport: viewport(0) }, + { at: 16, regions: { changing: region({ top: 190, bottom: 230 }) }, viewport: viewport(0) }, + { at: 32, regions: { changing: region({ top: 180, bottom: 220 }) }, viewport: viewport(0) }, + ]), + { preserveBottomAnchor: true, maxPositionReversals: 0 }, + ), + ).toEqual([]) +}) + +test("ignores overlap entirely outside the clipped timeline viewport", () => { + expect( + analyzeVisualStability( + trace([ + { + at: 0, + regions: { + changing: region({ top: -200, bottom: -100 }), + following: region({ top: -150, bottom: -50 }), + }, + viewport: viewport(0), + }, + ]), + { flow: ["changing", "following"] }, + ), + ).toEqual([]) +}) + +test("reports visible anchor movement while allowing virtual scrollbar movement", () => { + const issues = analyzeVisualStability( + trace([ + { at: 0, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: { ...viewport(100), scrollTop: 40 } }, + { at: 16, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: { ...viewport(120), scrollTop: 60 } }, + ]), + { fixed: ["anchor"] }, + ) + + expect(issues).toEqual([]) + + const moved = analyzeVisualStability( + trace([ + { at: 0, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: viewport(100) }, + { at: 16, regions: { anchor: region({ top: 112, bottom: 132 }) }, viewport: viewport(100) }, + ]), + { fixed: ["anchor"] }, + ) + expect(moved.some((issue) => issue.includes("anchor moved 12px in the viewport"))).toBe(true) +}) + +test("analyzes each marked event independently", () => { + const input: VisualStabilityTrace = { + markers: [ + { at: 10, label: "grow" }, + { at: 40, label: "shrink" }, + ], + samples: [ + frame(0, region({ top: 100 })), + frame(16, region({ top: 90 })), + frame(32, region({ top: 80 })), + frame(48, region({ top: 90 })), + frame(64, region({ top: 100 })), + ], + } + + expect(analyzeVisualStability(input, { maxPositionReversals: 0 })).toContain("changing top reversed 1 times") + expect( + analyzeVisualStabilityByMarker(input, { + maxPositionReversals: 0, + motion: ["changing"], + aggregateMotion: false, + }), + ).toEqual([]) +}) + +test("reports cross-event motion reversals by default", () => { + const input: VisualStabilityTrace = { + markers: [ + { at: 10, label: "up" }, + { at: 40, label: "down" }, + ], + samples: [ + frame(0, region({ top: 100 })), + frame(16, region({ top: 90 })), + frame(32, region({ top: 80 })), + frame(48, region({ top: 90 })), + frame(64, region({ top: 100 })), + ], + } + + expect(analyzeVisualStabilityByMarker(input, { maxPositionReversals: 0 })).toContain("changing top reversed 1 times") +}) + +test("reports regions rendered in the wrong flow order", () => { + const issues = analyzeVisualStability( + trace([frame(0, region({ top: 100, bottom: 120 }), region({ top: 60, bottom: 80 }))]), + { flow: ["changing", "following"] }, + ) + + expect(issues.some((issue) => issue.includes("changing rendered after following"))).toBe(true) +}) + +test("uses painted bounds instead of clipped layout overflow", () => { + expect( + analyzeVisualStability( + trace([ + frame( + 0, + region({ top: 100, bottom: 140, height: 40, layoutTop: 100, layoutBottom: 300 }), + region({ top: 140, bottom: 180 }), + ), + ]), + { flow: ["changing", "following"] }, + ), + ).toEqual([]) +}) + +test("does not report disappearance when a present row moves outside the viewport", () => { + expect( + analyzeVisualStability( + trace([ + frame(0, region({ visible: true })), + frame(16, region({ visible: false, inViewport: false, top: -100, bottom: -80 })), + frame(32, region({ visible: true })), + ]), + ), + ).toEqual([]) +}) + +test("reports an in-viewport transparent frame between visible frames", () => { + const issues = analyzeVisualStability( + trace([ + frame(0, region()), + frame(16, region({ visible: false, opacity: 0, inViewport: true })), + frame(32, region()), + ]), + ) + + expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true) +}) + +test("reports an in-viewport display-none frame between visible frames", () => { + const issues = analyzeVisualStability( + trace([ + frame(0, region()), + frame(16, region({ visible: false, width: 0, height: 0, inViewport: true, cssHidden: true })), + frame(32, region()), + ]), + ) + + expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true) +}) + +test("can limit motion analysis to unaffected regions", () => { + expect( + analyzeVisualStability( + trace([ + frame(0, region({ height: 20 }), region()), + frame(16, region({ height: 40 }), region()), + frame(32, region({ height: 30 }), region()), + ]), + { motion: ["following"] }, + ), + ).toEqual([]) +}) + +test("reports a blank frame across replacement surfaces", () => { + const issues = analyzeVisualStability( + { + markers: [], + samples: [ + { at: 0, regions: { thinking: region(), error: region({ present: false, visible: false }) } }, + { + at: 16, + regions: { + thinking: region({ present: false, visible: false }), + error: region({ present: false, visible: false }), + }, + }, + { at: 32, regions: { thinking: region({ present: false, visible: false }), error: region() } }, + ], + }, + { continuousAny: [["thinking", "error"]] }, + ) + + expect(issues.some((issue) => issue.includes("thinking | error blanked"))).toBe(true) +}) + +test("reports failure to acquire the bottom anchor", () => { + const issues = analyzeVisualStability( + trace([ + { at: 0, regions: { changing: region() }, viewport: viewport(600) }, + { at: 16, regions: { changing: region() }, viewport: viewport(120) }, + ]), + { acquireBottomAnchor: true }, + ) + + expect(issues.some((issue) => issue.includes("did not acquire bottom anchor"))).toBe(true) +}) + +test("reports a required region that never renders", () => { + const issues = analyzeVisualStability( + trace([ + { + at: 0, + regions: { changing: region({ present: false, visible: false }) }, + }, + ]), + { required: ["changing"] }, + ) + + expect(issues).toContain("changing never rendered") +}) + +test("preserves typed region names while mapping definitions", () => { + const regions = defineVisualRegions({ + changing: { selector: "[data-changing]" }, + following: { selector: "[data-following]", closest: "[data-row]" }, + }) + const selectors = mapVisualRegions(regions, (region) => region.selector) + + expect(selectors).toEqual({ changing: "[data-changing]", following: "[data-following]" }) + const name: keyof typeof selectors = "changing" + expect(name).toBe("changing") +}) + +test("evaluates the typed invariant algebra over explicit observations", () => { + const regions = defineVisualRegions({ + changing: { selector: "[data-changing]" }, + following: { selector: "[data-following]" }, + }) + const invariants = [ + { type: "required", regions: ["changing"] }, + { type: "flow", regions: ["changing", "following"] }, + ] satisfies VisualInvariant[] + // @ts-expect-error Plans reject names that are not in the region definition. + const invalid = { type: "required", regions: ["missing"] } satisfies VisualInvariant + const plan = visualPlan(regions, invariants, { perMarker: true }) + + expect(invalid.regions).toEqual(["missing"]) + expect(plan.perMarker).toBe(true) + expect(analyzeVisualObservations([frame(0, region({ bottom: 50 }), region({ top: 49, bottom: 69 }))], plan)).toEqual([ + "changing overlapped following by 1px at 0ms", + ]) +}) + +test("legacy plan adapter preserves analyzer messages and order", () => { + const input = trace([ + frame(0, region({ label: "Exploring", opacity: 1, bottom: 40 }), region({ top: 40, bottom: 60 })), + frame(16, region({ label: "Explored", opacity: 0.2, bottom: 50 }), region({ top: 49, bottom: 69 })), + frame(32, region({ label: "Exploring", opacity: 1, bottom: 50 }), region({ top: 50, bottom: 70 })), + ]) + const options = { flow: ["changing", "following"], stable: ["changing"] } + + expect(analyzeVisualObservations(input.samples, legacyVisualPlan(options))).toEqual( + analyzeVisualStability(input, options), + ) +}) + +function frame( + at: number, + changing: VisualStabilityTrace["samples"][number]["regions"][string], + following?: VisualStabilityTrace["samples"][number]["regions"][string], +) { + return { at, regions: { changing, ...(following ? { following } : {}) } } +} + +function region(input: Partial = {}) { + return { + present: true, + visible: true, + inViewport: true, + top: 0, + bottom: 20, + width: 100, + height: 20, + opacity: 1, + count: 1, + node: 1, + label: "", + text: "", + layoutTop: input.top ?? 0, + layoutBottom: input.bottom ?? 20, + ...input, + } +} + +function viewport(distanceFromBottom: number) { + return { top: 0, bottom: 400, scrollTop: 100, scrollHeight: 500, clientHeight: 400, distanceFromBottom } +} diff --git a/packages/app/e2e/regression/cross-server-tab-close.spec.ts b/packages/app/e2e/regression/cross-server-tab-close.spec.ts new file mode 100644 index 000000000000..f09a2c7b63ae --- /dev/null +++ b/packages/app/e2e/regression/cross-server-tab-close.spec.ts @@ -0,0 +1,153 @@ +import { expect, test, type Page, type Route } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { currentSession } from "../utils/mock-server" + +const serverA = "http://127.0.0.1:4096" +const serverB = "http://127.0.0.1:4097" +const sessionA = session("ses_server_a", "C:/server-a", "Server A session") +const sessionB = session("ses_server_b", "/home/server-b", "Server B session") + +test("closing the active server's last tab opens the remaining server tab", async ({ page }) => { + const requests: string[] = [] + await mockServers(page, requests) + await page.addInitScript( + ({ serverB, sessionA, sessionB }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([ + { type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA }, + { type: "session", server: serverB, sessionId: sessionB }, + ]), + ) + }, + { serverB, sessionA: sessionA.id, sessionB: sessionB.id }, + ) + + const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}` + const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` + await page.goto(hrefA) + await expect(page.getByText(sessionA.title).first()).toBeVisible() + + const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`) + await tabA.locator('[data-slot="tab-close"] button').click() + + await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) + await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/session/${sessionB.id}`))).toBe(true) + await expect(page.getByText(sessionB.title).first()).toBeVisible() + const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`)) + expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true) + expect( + requests.some((request) => { + const url = new URL(request) + return url.origin === serverB && url.searchParams.get("directory") === sessionB.directory + }), + ).toBe(true) +}) + +test("legacy session routes preserve an existing tab's server", async ({ page }) => { + await mockServers(page, []) + await page.addInitScript( + ({ serverB, sessionB }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]), + ) + }, + { serverB, sessionB: sessionB.id }, + ) + + const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` + await page.goto(`/${base64Encode(sessionB.directory)}/session/${sessionB.id}`) + await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) +}) + +function session(id: string, directory: string, title: string) { + return { + id, + slug: id, + projectID: `project-${id}`, + directory, + title, + version: "dev", + time: { created: 1, updated: 1 }, + } +} + +async function mockServers(page: Page, requests: string[]) { + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()) + if (url.origin !== serverA && url.origin !== serverB) return route.fallback() + requests.push(url.toString()) + const current = url.origin === serverA ? sessionA : sessionB + const directory = url.searchParams.get("directory") + if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) + if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") + return sse(route) + if (url.pathname === "/global/health") return json(route, {}, 404) + if (url.pathname === "/api/health") return json(route, { pid: 1 }) + if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) + if (url.pathname === "/api/session/active") return json(route, { data: {} }) + if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) }) + if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} }) + if (url.pathname === `/session/${current.id}`) return json(route, current) + if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) + if (url.pathname === `/session/${current.id}/message`) return json(route, []) + if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) + if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) + return json(route, []) + if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) + if (url.pathname === "/provider") + return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) + if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) + if (url.pathname === "/project" || url.pathname === "/project/current") { + const project = { + id: current.projectID, + worktree: current.directory, + vcs: "git", + time: { created: 1, updated: 1 }, + sandboxes: [], + } + return json(route, url.pathname === "/project" ? [project] : project) + } + if (url.pathname === "/path") + return json(route, { + state: current.directory, + config: current.directory, + worktree: current.directory, + directory: current.directory, + home: current.directory, + }) + if (url.pathname === "/api/path") + return json(route, { + state: current.directory, + config: current.directory, + worktree: current.directory, + directory: current.directory, + home: current.directory, + }) + if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (url.pathname === "/api/vcs") + return json(route, { + location: { directory: current.directory }, + data: { branch: "main", defaultBranch: "main" }, + }) + return json(route, {}) + }) +} + +function json(route: Route, body: unknown, status = 200) { + return route.fulfill({ + status, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify(body), + }) +} + +function sse(route: Route) { + return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) +} diff --git a/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts b/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts new file mode 100644 index 000000000000..136232c2114b --- /dev/null +++ b/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts @@ -0,0 +1,150 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/FileBrowserSidebar" +const projectID = "proj_file_browser_sidebar" +const sessionID = "ses_file_browser_sidebar" +const title = "File browser sidebar" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` +const files = Array.from({ length: 80 }, (_, index) => `file-${String(index).padStart(2, "0")}.ts`) +// Marks the file-browser sidebar DOM node so a remount (fresh node) is detectable. +const PROBE = "original" + +test.use({ viewport: { width: 1440, height: 900 } }) + +// The file-browser sidebar must stay mounted across preview/pinned file-tab +// switches. Remounting resets scroll and filter state. +test("keeps the file-browser sidebar mounted when switching file tabs", async ({ page }) => { + await setup(page) + + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + const panel = page.locator("#review-panel") + await panel.getByRole("button", { name: "Open file" }).click() + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + + const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]') + await expect(sidebar).toBeVisible() + await expect(panel.getByRole("button", { name: "file-00.ts" })).toBeVisible() + + await panel.getByRole("button", { name: "file-00.ts" }).click() + await expect(panel.getByRole("tab", { name: "file-00.ts" })).toHaveAttribute("data-selected", "") + await expect(panel.getByText("contents:file-00.ts", { exact: true })).toBeVisible() + + const viewport = panel.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport') + await viewport.hover() + await page.mouse.wheel(0, 100_000) + await expect + .poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1) + const scrolled = await viewport.evaluate((element) => element.scrollTop) + expect(scrolled).toBeGreaterThan(0) + await writeProbe(page) + + await panel.getByRole("button", { name: "file-79.ts" }).click() + await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "") + await expect(panel.getByText("contents:file-79.ts", { exact: true })).toBeVisible() + expect(await readProbe(page)).toBe(PROBE) + await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled) + + await panel.getByRole("button", { name: "file-78.ts" }).dblclick() + await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "") + await panel.getByRole("button", { name: "file-79.ts" }).click() + await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "") + await panel.getByRole("tab", { name: "file-78.ts" }).click() + await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "") + expect(await readProbe(page)).toBe(PROBE) + await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled) +}) + +type Probed = HTMLElement & { __e2eProbe?: string } + +async function writeProbe(page: Page) { + await page.locator('#review-panel [data-component="session-review-v2-sidebar-root"]').evaluate((el, probe) => { + ;(el as Probed).__e2eProbe = probe + }, PROBE) +} + +async function readProbe(page: Page) { + return page + .locator('#review-panel [data-component="session-review-v2-sidebar-root"]') + .evaluate((el) => (el as Probed).__e2eProbe) +} + +async function setup(page: Page) { + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "file-browser-sidebar", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [ + { + id: sessionID, + slug: sessionID, + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + vcsDiff: [], + fileList: (path) => { + if (path) return [] + return files.map((name) => ({ + name, + path: name, + absolute: `${directory}/${name}`, + type: "file" as const, + ignored: false, + })) + }, + fileContent: (path) => ({ type: "text", content: `contents:${path}` }), + pageMessages: () => ({ items: [] }), + }) + + await page.addInitScript( + ({ directory, server, sessionID }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.global.dat:layout", + JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), + ) + localStorage.setItem( + "opencode.global.dat:review-panel-v2", + JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "session", server, sessionId: sessionID }]), + ) + }, + { directory, server, sessionID }, + ) +} diff --git a/packages/app/e2e/regression/legacy-new-session.spec.ts b/packages/app/e2e/regression/legacy-new-session.spec.ts new file mode 100644 index 000000000000..45cd64adcc69 --- /dev/null +++ b/packages/app/e2e/regression/legacy-new-session.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { mockOpenCodeServer } from "../utils/mock-server" + +const draftID = "draft_legacy_new_session" +const directory = "C:/OpenCode/LegacyNewSession" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + +test("redirects a draft to the legacy new-session route", async ({ page }) => { + await mockOpenCodeServer(page, { + directory, + project: { + id: "proj_legacy_new_session", + worktree: directory, + vcs: "git", + name: "legacy-new-session", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { all: [], connected: [], default: {} }, + sessions: [], + pageMessages: () => ({ items: [] }), + }) + await page.addInitScript( + ({ directory, draftID, server }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } })) + localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" })) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "draft", draftID, server, directory }]), + ) + }, + { directory, draftID, server }, + ) + + await page.goto(`/new-session?draftId=${draftID}`) + + await expect(page).toHaveURL(`/${base64Encode(directory)}/session`) + await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible() + await expect(page.locator('[data-component="prompt-input"]')).toBeVisible() +}) diff --git a/packages/app/e2e/regression/new-session-panel-corner.spec.ts b/packages/app/e2e/regression/new-session-panel-corner.spec.ts new file mode 100644 index 000000000000..a17b8c081709 --- /dev/null +++ b/packages/app/e2e/regression/new-session-panel-corner.spec.ts @@ -0,0 +1,83 @@ +import { expect, test } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +const draftID = "draft_new_session_panel_corner" +const directory = "C:/OpenCode/NewSessionPanelCorner" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + +test.use({ + viewport: { width: 935, height: 522 }, + deviceScaleFactor: 1, +}) + +test("matches the rounded panel corners to the dark new-session background", async ({ page }, testInfo) => { + await mockOpenCodeServer(page, { + directory, + project: { + id: "proj_new_session_panel_corner", + worktree: directory, + vcs: "git", + name: "new-session-panel-corner", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { all: [], connected: [], default: {} }, + sessions: [], + pageMessages: () => ({ items: [] }), + }) + await page.addInitScript( + ({ directory, draftID, server }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem("opencode-theme-id", "oc-2") + localStorage.setItem("opencode-color-scheme", "dark") + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "draft", draftID, server, directory }]), + ) + }, + { directory, draftID, server }, + ) + + await page.goto(`/new-session?draftId=${draftID}`) + await expectAppVisible(page.locator('[data-component="prompt-input"]')) + await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark") + const panel = page.locator('main div[class*="rounded-[10px]"][class*="overflow-hidden"]') + await expect(panel).toHaveCount(1) + const box = await panel.boundingBox() + if (!box) throw new Error("New-session panel bounds are unavailable") + + const screenshot = await page.screenshot({ path: testInfo.outputPath("new-session-dark.png") }) + const corners = await page.evaluate( + async ({ source, points }) => { + const image = new Image() + image.src = source + await image.decode() + const canvas = document.createElement("canvas") + canvas.width = image.naturalWidth + canvas.height = image.naturalHeight + const context = canvas.getContext("2d") + if (!context) throw new Error("2D canvas is unavailable") + context.drawImage(image, 0, 0) + return points.map((point) => Array.from(context.getImageData(point.x, point.y, 1, 1).data)) + }, + { + source: `data:image/png;base64,${screenshot.toString("base64")}`, + points: [ + { x: Math.floor(box.x), y: Math.floor(box.y) }, + { x: Math.ceil(box.x + box.width) - 1, y: Math.floor(box.y) }, + { x: Math.floor(box.x), y: Math.ceil(box.y + box.height) - 1 }, + { x: Math.ceil(box.x + box.width) - 1, y: Math.ceil(box.y + box.height) - 1 }, + ], + }, + ) + + expect(corners.every(([red, green, blue, alpha]) => red <= 8 && green <= 8 && blue <= 8 && alpha === 255)).toBe(true) +}) diff --git a/packages/app/e2e/regression/open-file-expand-folder.spec.ts b/packages/app/e2e/regression/open-file-expand-folder.spec.ts new file mode 100644 index 000000000000..37739d2fbbc8 --- /dev/null +++ b/packages/app/e2e/regression/open-file-expand-folder.spec.ts @@ -0,0 +1,132 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/OpenFileExpand" +const projectID = "proj_open_file_expand" +const sessionID = "ses_open_file_expand" +const title = "Open file expand" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + +test.use({ viewport: { width: 1440, height: 900 } }) + +test("expands a folder whose path has a trailing Windows separator", async ({ page }) => { + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "open-file-expand", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [ + { + id: sessionID, + slug: sessionID, + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + vcsDiff: [], + fileList: (path) => { + if (path === "frontend\\" || path === "frontend") { + return [ + { + name: "app.ts", + path: "frontend\\app.ts", + absolute: `${directory}/frontend/app.ts`, + type: "file" as const, + ignored: false, + }, + ] + } + if (path) return [] + return [ + { + name: "frontend", + path: "frontend\\", + absolute: `${directory}/frontend`, + type: "directory" as const, + ignored: false, + }, + { + name: "README.md", + path: "README.md", + absolute: `${directory}/README.md`, + type: "file" as const, + ignored: false, + }, + ] + }, + fileContent: (path) => ({ type: "text", content: `contents:${path}` }), + pageMessages: () => ({ items: [] }), + }) + + await page.addInitScript( + ({ directory, server, sessionID }) => { + localStorage.setItem( + "settings.v3", + JSON.stringify({ general: { newLayoutDesigns: true, shouldDisplayTabsToast: false } }), + ) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.global.dat:layout", + JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), + ) + localStorage.setItem( + "opencode.global.dat:review-panel-v2", + JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "session", server, sessionId: sessionID }]), + ) + }, + { directory, server, sessionID }, + ) + + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + const panel = page.locator("#review-panel") + await panel.getByRole("button", { name: "Open file" }).click() + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + + const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]') + await expect(sidebar).toBeVisible() + + const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]') + await expect(frontendRow).toBeVisible() + await expect(frontendRow).toHaveAttribute("aria-expanded", "false") + await frontendRow.click() + await expect(frontendRow).toHaveAttribute("aria-expanded", "true") + + const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]') + await expect(appRow).toBeVisible() + await appRow.click() + await expect(panel.getByRole("tab", { name: "app.ts" })).toHaveAttribute("data-selected", "") + await expect(panel.getByText("contents:frontend/app.ts", { exact: true })).toBeVisible() +}) diff --git a/packages/app/e2e/regression/project-picker-recent-search.spec.ts b/packages/app/e2e/regression/project-picker-recent-search.spec.ts new file mode 100644 index 000000000000..2cdb0b4a03b4 --- /dev/null +++ b/packages/app/e2e/regression/project-picker-recent-search.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from "@playwright/test" +import type { Page } from "@playwright/test" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"] +const worktrees = NAMES.map((name) => `/opencode-demo/${name}`) + +// The sixth project sits outside the five-item recent cap, so it is only reachable if the +// dialog hands every recent project to the list filter instead of a pre-truncated slice. +const OUTSIDE_CAP = "foxtrot-docs" + +// Dialog rows carry data-directory-path; the sidebar project list does not, so this +// scopes assertions to the picker instead of matching the sidebar entry of the same name. +const rows = (page: Page) => page.locator("[data-directory-path]") +const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`) + +async function openProjectDialog(page: Page) { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + fileList: () => [], + findFiles: () => [], + }) + await page.addInitScript((dirs) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) }, + lastProject: {}, + }), + ) + }, worktrees) + await page.goto("/") + const add = page.getByRole("button", { name: "Add project" }).first() + await expectAppVisible(add) + await add.click() + await expect(rows(page)).toHaveCount(5) + return page.getByRole("textbox").last() +} + +test("searches every recent project, not just the five most recent", async ({ page }) => { + const search = await openProjectDialog(page) + await expect(row(page, OUTSIDE_CAP)).toHaveCount(0) + + await search.fill("foxtrot") + + await expect(row(page, OUTSIDE_CAP)).toHaveCount(1) +}) + +test("still caps the idle recent list at five projects", async ({ page }) => { + await openProjectDialog(page) + + await expect(row(page, NAMES[4])).toHaveCount(1) + await expect(row(page, OUTSIDE_CAP)).toHaveCount(0) +}) diff --git a/packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts b/packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts new file mode 100644 index 000000000000..50dcc8820b45 --- /dev/null +++ b/packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +const directory = "C:/OpenCode/PromptInputV2Editing" +const projectID = "proj_prompt_input_v2_editing" +const sessionID = "ses_prompt_input_v2_editing" + +test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => { + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "prompt-input-v2-editing", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { all: [], connected: [], default: {} }, + sessions: [ + { + id: sessionID, + slug: "prompt-input-v2-editing", + projectID, + directory, + title: "Prompt input V2 editing", + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + pageMessages: () => ({ items: [] }), + }) + await page.addInitScript(() => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + const composer = page.locator('[data-component="prompt-input-v2"]') + const input = composer.locator('[data-component="prompt-input"]') + await expectAppVisible(composer) + + await input.fill("keep me") + await composer.getByRole("button", { name: "Add images and files" }).click() + await page.getByRole("menuitem", { name: "Commands" }).click() + await page.locator('[data-suggestion-id="model.choose"]').click() + + await expect(input).toHaveText("keep me") +}) diff --git a/packages/app/e2e/regression/prompt-thinking-level.spec.ts b/packages/app/e2e/regression/prompt-thinking-level.spec.ts index d12684c143fd..9315c347c036 100644 --- a/packages/app/e2e/regression/prompt-thinking-level.spec.ts +++ b/packages/app/e2e/regression/prompt-thinking-level.spec.ts @@ -54,19 +54,16 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => { }) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - const composer = page.locator('[data-component="session-composer"]') + const composer = page.locator('[data-component="prompt-input-v2"]') const input = composer.locator('[data-component="prompt-input"]') - const control = composer.locator('[data-component="prompt-variant-control"]') + const control = composer.getByRole("button", { name: "Choose model variant" }) await expectAppVisible(composer) await idleComposer(page) - await expect(control).toBeHidden() - - await composer.hover() await expect(control).toBeVisible() - await control.locator('[data-action="prompt-model-variant"]').click() - const high = page.getByRole("option", { name: "high" }) + await control.click() + const high = page.getByRole("menuitemradio", { name: "high" }) await expect(high).toBeVisible() await page.mouse.move(0, 0) await expect(control).toBeVisible() diff --git a/packages/app/e2e/regression/remote-session-settings.spec.ts b/packages/app/e2e/regression/remote-session-settings.spec.ts new file mode 100644 index 000000000000..35a0aa44cda8 --- /dev/null +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -0,0 +1,302 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page, type Route } from "@playwright/test" +import { installSseTransport } from "../utils/sse-transport" +import { currentSession } from "../utils/mock-server" + +const serverA = "http://127.0.0.1:4096" +const serverB = "http://127.0.0.1:4097" +const directoryA = "C:/server-a" +const directoryB = "/home/server-b" +const sessionA = session("ses_server_a", directoryA, "Server A session") +const childSessionA = { ...session("ses_server_a_child", directoryA, "Server A child session"), parentID: sessionA.id } +const sessionB = session("ses_server_b", directoryB, "Server B session") + +test("session settings use the remote server context", async ({ page }) => { + const permissionRequests: string[] = [] + await mockServers(page, permissionRequests) + await configureServers(page) + + await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`) + await expect(page.getByText(sessionB.title).first()).toBeVisible() + await page.keyboard.press("Control+,") + + const dialog = page.locator(".settings-v2-dialog") + const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]') + const input = autoAccept.getByRole("switch") + await expect(autoAccept).toBeVisible() + await expect(input).toBeEnabled() + permissionRequests.length = 0 + await autoAccept.locator('[data-slot="switch-control"]').click() + await expect(input).toBeChecked() + await expect + .poll(() => + permissionRequests.some((request) => { + const url = new URL(request) + return url.origin === serverB && url.searchParams.get("directory") === directoryB + }), + ) + .toBe(true) + expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true) + + await dialog.getByRole("tab", { name: "Models" }).click() + await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled() + await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0) +}) + +test("auto-accept responds for an unfocused server session", async ({ page }) => { + const permissionRequests: string[] = [] + const permissionResponses: PermissionResponse[] = [] + const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { + server: serverA, + retry: 20, + }) + await mockServers(page, permissionRequests, permissionResponses) + await configureServers(page, [ + { type: "session", server: serverA, sessionId: sessionA.id }, + { type: "session", server: serverB, sessionId: sessionB.id }, + ]) + + const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` + await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`) + await expect(page.getByText(sessionA.title).first()).toBeVisible() + await page.keyboard.press("Control+,") + const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]') + await autoAccept.locator('[data-slot="switch-control"]').click() + await expect(autoAccept.getByRole("switch")).toBeChecked() + await expect + .poll(() => + permissionRequests.some((request) => { + const url = new URL(request) + return url.origin === serverA && url.searchParams.get("directory") === directoryA + }), + ) + .toBe(true) + await page.keyboard.press("Escape") + + await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click() + await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) + await expect(page.getByText(sessionB.title).first()).toBeVisible() + await transport.waitForConnection() + + await transport.send({ + directory: directoryA, + payload: { + id: "event-permission-background-a", + type: "permission.asked", + properties: { + id: "permission-background-a", + sessionID: sessionA.id, + permission: "bash", + patterns: ["git status"], + metadata: {}, + always: [], + }, + }, + }) + + await expect + .poll(() => permissionResponses) + .toEqual([ + { + origin: serverA, + directory: directoryA, + sessionID: sessionA.id, + permissionID: "permission-background-a", + body: { response: "once" }, + }, + ]) + + await transport.send({ + directory: directoryA, + payload: { + id: "event-permission-background-a-child", + type: "permission.asked", + properties: { + id: "permission-background-a-child", + sessionID: childSessionA.id, + permission: "bash", + patterns: ["git diff"], + metadata: {}, + always: [], + }, + }, + }) + + await expect + .poll(() => permissionResponses) + .toEqual([ + { + origin: serverA, + directory: directoryA, + sessionID: sessionA.id, + permissionID: "permission-background-a", + body: { response: "once" }, + }, + { + origin: serverA, + directory: directoryA, + sessionID: childSessionA.id, + permissionID: "permission-background-a-child", + body: { response: "once" }, + }, + ]) +}) + +type PermissionResponse = { + origin: string + directory?: string + sessionID: string + permissionID: string + body: unknown +} + +async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) { + await page.addInitScript( + ({ serverB, tabs }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) + localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify(tabs)) + }, + { serverB, tabs }, + ) +} + +async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) { + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()) + if (url.origin !== serverA && url.origin !== serverB) return route.fallback() + const remote = url.origin === serverB + const directory = remote ? directoryB : directoryA + const sessions = remote ? [sessionB] : [sessionA, childSessionA] + const requestDirectory = url.searchParams.get("directory") + const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/) + if (route.request().method() === "POST" && response) { + permissionResponses.push({ + origin: url.origin, + directory: requestDirectory ?? undefined, + sessionID: response[1]!, + permissionID: response[2]!, + body: route.request().postDataJSON(), + }) + return json(route, true) + } + if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) + if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") + return sse(route) + if (url.pathname === "/global/health") return json(route, { healthy: true }) + if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent") + return json(route, { data: [] }) + if (url.pathname === "/api/model/default") return json(route, { data: null }) + if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname)) + return json(route, { location: { directory }, data: [] }) + if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] }) + if (url.pathname === "/api/mcp/resource") + return json(route, { location: { directory }, data: { resources: [], templates: [] } }) + if (url.pathname === "/api/project") { + return json(route, [ + { + id: remote ? sessionB.projectID : "project-server-a", + worktree: directory, + vcs: "git", + time: { created: 1, updated: 1 }, + sandboxes: [], + }, + ]) + } + if (url.pathname === "/api/project/current") + return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory }) + if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) + if (url.pathname === "/api/session/active") return json(route, { data: {} }) + const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`) + if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) + if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`)) + return json(route, { data: [], cursor: {} }) + const current = sessions.find((session) => url.pathname === `/session/${session.id}`) + if (current) return json(route, current) + if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) + if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, []) + if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) + if (url.pathname === "/permission") { + permissionRequests.push(url.toString()) + return json(route, []) + } + if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname)) + return json(route, []) + if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) + if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a")) + if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) + if (url.pathname === "/project" || url.pathname === "/project/current") { + const project = { + id: remote ? sessionB.projectID : "project-server-a", + worktree: directory, + vcs: "git", + time: { created: 1, updated: 1 }, + sandboxes: [], + } + return json(route, url.pathname === "/project" ? [project] : project) + } + if (url.pathname === "/path") + return json(route, { + state: directory, + config: directory, + worktree: directory, + directory, + home: directory, + }) + if (url.pathname === "/api/path") + return json(route, { state: directory, config: directory, worktree: directory, directory, home: directory }) + if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (url.pathname === "/api/vcs") + return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } }) + if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] }) + return json(route, {}) + }) +} + +function session(id: string, directory: string, title: string) { + return { + id, + slug: id, + projectID: `project-${id}`, + directory, + title, + version: "dev", + time: { created: 1, updated: 1 }, + } +} + +function provider(id: string) { + const name = id === "server-b" ? "Server B" : "Server A" + return { + all: [ + { + id, + name: `${name} Provider`, + models: { + [id]: { + id, + name: `${name} Model`, + family: id, + release_date: "2026-01-01", + limit: { context: 200_000 }, + }, + }, + }, + ], + connected: [id], + default: { providerID: id, modelID: id }, + } +} + +function json(route: Route, body: unknown, status = 200) { + return route.fulfill({ + status, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify(body), + }) +} + +function sse(route: Route) { + return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) +} diff --git a/packages/app/e2e/regression/remote-tab-busy.spec.ts b/packages/app/e2e/regression/remote-tab-busy.spec.ts new file mode 100644 index 000000000000..2d9b1e234971 --- /dev/null +++ b/packages/app/e2e/regression/remote-tab-busy.spec.ts @@ -0,0 +1,131 @@ +import { expect, test, type Page, type Route } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { currentSession } from "../utils/mock-server" + +const serverA = "http://127.0.0.1:4096" +const serverB = "http://127.0.0.1:4097" +const sessionA = session("ses_server_a", "C:/server-a", "Server A session") +const sessionB = session("ses_server_b", "/home/server-b", "Server B session") + +test("tab busy indicator reflects the tab server's own session status", async ({ page }) => { + await mockServers(page) + await page.addInitScript( + ({ serverA, serverB, sessionA, sessionB }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([ + { type: "session", server: serverA, sessionId: sessionA }, + { type: "session", server: serverB, sessionId: sessionB }, + ]), + ) + }, + { serverA, serverB, sessionA: sessionA.id, sessionB: sessionB.id }, + ) + + const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}` + const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` + await page.goto(hrefA) + await expect(page.getByText(sessionA.title).first()).toBeVisible() + + // Session B is busy on server B while server A stays the active server, so the + // busy indicator must come from the tab server's status, not the active server's. + const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`) + await expect(tabB.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible() + + const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`) + await expect(tabA.locator("[data-titlebar-tab-title]")).toHaveText(sessionA.title) + await expect(tabA.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0) +}) + +function session(id: string, directory: string, title: string) { + return { + id, + slug: id, + projectID: `project-${id}`, + directory, + title, + version: "dev", + time: { created: 1, updated: 1 }, + } +} + +async function mockServers(page: Page) { + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()) + if (url.origin !== serverA && url.origin !== serverB) return route.fallback() + const current = url.origin === serverA ? sessionA : sessionB + const directory = url.searchParams.get("directory") + if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) + if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") + return sse(route, url.pathname === "/api/event") + if (url.pathname === "/global/health") return json(route, {}, 404) + if (url.pathname === "/api/health") return json(route, { pid: 1 }) + if (url.pathname === "/api/session/active") + return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} }) + if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) + if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) }) + if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} }) + if (url.pathname === `/session/${current.id}`) return json(route, current) + if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) + if (url.pathname === `/session/${current.id}/message`) return json(route, []) + if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) + if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) + return json(route, []) + if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) + if (url.pathname === "/provider") + return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) + if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) + if (url.pathname === "/project" || url.pathname === "/project/current") { + const project = { + id: current.projectID, + worktree: current.directory, + vcs: "git", + time: { created: 1, updated: 1 }, + sandboxes: [], + } + return json(route, url.pathname === "/project" ? [project] : project) + } + if (url.pathname === "/path") + return json(route, { + state: current.directory, + config: current.directory, + worktree: current.directory, + directory: current.directory, + home: current.directory, + }) + if (url.pathname === "/api/path") + return json(route, { + state: current.directory, + config: current.directory, + worktree: current.directory, + directory: current.directory, + home: current.directory, + }) + if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (url.pathname === "/api/vcs") + return json(route, { + location: { directory: current.directory }, + data: { branch: "main", defaultBranch: "main" }, + }) + return json(route, {}) + }) +} + +function json(route: Route, body: unknown, status = 200) { + return route.fulfill({ + status, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify(body), + }) +} + +function sse(route: Route, current: boolean) { + return route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n", + }) +} diff --git a/packages/app/e2e/regression/review-image-flash.spec.ts b/packages/app/e2e/regression/review-image-flash.spec.ts new file mode 100644 index 000000000000..dd200384d49a --- /dev/null +++ b/packages/app/e2e/regression/review-image-flash.spec.ts @@ -0,0 +1,202 @@ +import { expect, test, type Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible, expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/ReviewImageFlashRegression" +const sessionID = "ses_review_image_flash_regression" +const title = "Review image flash regression" +const imageFile = "assets/preview.png" + +test("clicking an image file in the v2 review pane does not blank the panel", async ({ page }) => { + await openReview(page) + await installReviewFlashProbe(page) + + await page.getByRole("button", { name: /preview\.png/ }).click() + await waitForReviewFlashProbe(page, 400) + const trace = await collectReviewFlashProbe(page) + const bad = trace.samples.filter((sample) => sample.blank || sample.blackCenter) + + expect(trace.samples.length).toBeGreaterThan(0) + expect( + bad, + JSON.stringify({ bad: bad.slice(0, 8), first: trace.samples.slice(0, 8), last: trace.samples.slice(-4) }, null, 2), + ).toEqual([]) +}) + +async function openReview(page: Page) { + await page.setViewportSize({ width: 960, height: 900 }) + await page.addInitScript(() => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + }) + await mockOpenCodeServer(page, { + directory, + project: { + id: "proj_review_image_flash_regression", + worktree: directory, + vcs: "git", + name: "review-image-flash-regression", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { all: [], connected: [], default: {} }, + sessions: [ + { + id: sessionID, + slug: "review-image-flash-regression", + projectID: "proj_review_image_flash_regression", + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + vcsDiff: [ + { + file: "src/example.ts", + additions: 1, + deletions: 1, + status: "modified", + patch: + "diff --git a/src/example.ts b/src/example.ts\n--- a/src/example.ts\n+++ b/src/example.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n", + }, + { + file: imageFile, + patch: "", + additions: 1, + deletions: 0, + status: "added", + }, + ], + fileContent: async (path) => { + if (path !== imageFile) return undefined + await new Promise((resolve) => setTimeout(resolve, 250)) + return { + type: "binary", + content: "iVBORw0KGgo=", + encoding: "base64", + mimeType: "image/png", + } + }, + fileList: (path) => { + if (!path) { + return [ + { name: "assets", path: "assets", absolute: `${directory}/assets`, type: "directory", ignored: false }, + { name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false }, + ] + } + if (path === "assets") { + return [ + { + name: "preview.png", + path: imageFile, + absolute: `${directory}/${imageFile}`, + type: "file", + ignored: false, + }, + ] + } + if (path === "src") { + return [ + { + name: "example.ts", + path: "src/example.ts", + absolute: `${directory}/src/example.ts`, + type: "file", + ignored: false, + }, + ] + } + return [] + }, + pageMessages: () => ({ + items: [ + { + info: { + id: "msg_review_image_flash_regression", + sessionID, + role: "user", + time: { created: 1700000000000 }, + summary: { diffs: [] }, + agent: "build", + model: { providerID: "opencode", modelID: "test" }, + }, + parts: [ + { + id: "prt_review_image_flash_regression", + sessionID, + messageID: "msg_review_image_flash_regression", + type: "text", + text: "Review this change.", + }, + ], + }, + ], + }), + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + await page.getByRole("button", { name: "Toggle review" }).click() + await expectAppVisible(page.locator('#review-panel [data-component="session-review-v2"]')) + await expectAppVisible(page.getByRole("button", { name: /preview\.png/ })) +} + +async function installReviewFlashProbe(page: Page) { + await page.evaluate(() => { + const samples: Array<{ + observedAtMs: number + blank: boolean + blackCenter: boolean + text: string + background: string + }> = [] + const startedAt = performance.now() + const sample = () => { + const panel = document.querySelector('#review-panel [data-component="session-review-v2"]') + const rect = panel?.getBoundingClientRect() + const center = rect + ? document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2) + : undefined + const background = center instanceof Element ? getComputedStyle(center).backgroundColor : "" + samples.push({ + observedAtMs: performance.now() - startedAt, + blank: !panel || panel.textContent?.trim().length === 0, + blackCenter: background === "rgb(0, 0, 0)", + text: panel?.textContent?.trim().slice(0, 80) ?? "", + background, + }) + if (performance.now() - startedAt < 500) requestAnimationFrame(sample) + } + document.addEventListener( + "click", + (event) => { + const target = event.target instanceof Element ? event.target : undefined + if (!target?.closest('[data-slot="file-tree-v2-row"]')) return + requestAnimationFrame(sample) + }, + { capture: true, once: true }, + ) + ;(window as Window & { __reviewImageFlash?: { samples: typeof samples; startedAt: number } }).__reviewImageFlash = { + samples, + startedAt, + } + }) +} + +async function waitForReviewFlashProbe(page: Page, durationMs: number) { + await page.waitForFunction((durationMs) => { + const state = (window as Window & { __reviewImageFlash?: { samples: unknown[]; startedAt: number } }) + .__reviewImageFlash + return !!state && state.samples.length > 0 && performance.now() - state.startedAt >= durationMs + }, durationMs) +} + +async function collectReviewFlashProbe(page: Page) { + return page.evaluate(() => { + return (window as Window & { __reviewImageFlash?: { samples: unknown[]; startedAt: number } }).__reviewImageFlash! + }) as Promise<{ + startedAt: number + samples: Array<{ observedAtMs: number; blank: boolean; blackCenter: boolean; text: string; background: string }> + }> +} diff --git a/packages/app/e2e/regression/review-line-comment.spec.ts b/packages/app/e2e/regression/review-line-comment.spec.ts new file mode 100644 index 000000000000..3e50dab65d93 --- /dev/null +++ b/packages/app/e2e/regression/review-line-comment.spec.ts @@ -0,0 +1,169 @@ +import { expect, test, type Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible, expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/ReviewLineCommentRegression" +const sessionID = "ses_review_line_comment_regression" +const title = "Review line comment regression" + +test.beforeEach(async ({ page }) => { + await openReview(page) +}) + +test("opens the comment editor when code is clicked", async ({ page }) => { + const review = page.locator('[data-component="session-review"]') + const line = review.getByText("export const value = 'after'", { exact: true }) + await expectAppVisible(line) + await line.click() + + await expect(review.getByRole("textbox")).toBeVisible() + await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2") +}) + +test("opens the comment editor when a line number is clicked", async ({ page }) => { + const review = page.locator('[data-component="session-review"]') + const lineNumber = review.locator('[data-column-number="1"]').last() + await expectAppVisible(lineNumber) + await lineNumber.click() + + await expect(review.getByRole("textbox")).toBeVisible() + await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1") +}) + +test("opens the comment editor for a line number range", async ({ page }) => { + const review = page.locator('[data-component="session-review"]') + const start = review.locator('[data-column-number="1"]').last() + const end = review.locator('[data-column-number="3"]').last() + await expectAppVisible(start) + await expectAppVisible(end) + + await start.dragTo(end) + + await expect(review.getByRole("textbox")).toBeVisible() + await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3") +}) + +test("shows a comment button when a line number is hovered", async ({ page }) => { + const review = page.locator('[data-component="session-review"]') + const lineNumber = review.locator('[data-column-number="1"]').last() + await expectAppVisible(lineNumber) + + const comment = review.getByRole("button", { name: "Comment", exact: true }) + await expect(async () => { + await lineNumber.hover() + await expect(lineNumber).toHaveAttribute("data-hovered", "") + await expect(comment).toHaveCount(1) + await expect(comment).toHaveCSS("pointer-events", "auto") + await comment.focus() + await expect(comment).toBeFocused() + }).toPass({ timeout: 10_000 }) + await comment.press("Enter") + await expect(review.getByRole("textbox")).toBeVisible() + await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1") +}) + +test("stages a submitted line comment in the prompt context", async ({ page }) => { + page.on("request", (request) => { + expect.soft(request.method(), `unexpected ${request.method()} ${new URL(request.url()).pathname}`).toBe("GET") + }) + + const review = page.locator('[data-component="session-review"]') + await review.getByText("export const value = 'after'", { exact: true }).click() + const textbox = review.getByRole("textbox") + await expect(textbox).toBeVisible() + await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2") + await textbox.fill("Use the existing value instead") + const submit = review.locator('[data-slot="line-comment-action"][data-variant="primary"]') + await expect(submit).toBeEnabled() + await submit.click() + + await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible() + await page.getByRole("tab", { name: "Session" }).click() + const context = page.getByText("Use the existing value instead", { exact: true }).last() + await expect(context).toBeVisible() + await expect(context.locator("..")).toContainText("review.ts:2") +}) + +async function openReview(page: Page) { + await page.setViewportSize({ width: 700, height: 900 }) + await mockOpenCodeServer(page, { + protocol: "v2", + directory, + project: { + id: "proj_review_line_comment_regression", + worktree: directory, + vcs: "git", + name: "review-line-comment-regression", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { all: [], connected: [], default: {} }, + sessions: [ + { + id: sessionID, + slug: "review-line-comment-regression", + projectID: "proj_review_line_comment_regression", + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + vcsDiff: [ + { + file: "src/review.ts", + additions: 1, + deletions: 1, + status: "modified", + patch: + "diff --git a/src/review.ts b/src/review.ts\n--- a/src/review.ts\n+++ b/src/review.ts\n@@ -1,3 +1,3 @@\n export const first = 1\n-export const value = 'before'\n+export const value = 'after'\n export const last = 3\n", + }, + ], + pageMessages: () => ({ + items: [ + { + info: { + id: "msg_review_line_comment_regression", + sessionID, + role: "user", + time: { created: 1700000000000 }, + summary: { diffs: [] }, + agent: "build", + model: { providerID: "opencode", modelID: "test" }, + }, + parts: [ + { + id: "prt_review_line_comment_regression", + sessionID, + messageID: "msg_review_line_comment_regression", + type: "text", + text: "Review this change.", + }, + ], + }, + ], + }), + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + const changes = page.getByRole("tab", { name: "Changes" }) + const diffResponse = page.waitForResponse( + (response) => + response.request().method() === "GET" && response.ok() && new URL(response.url()).pathname === "/api/vcs/diff", + ) + await changes.click() + expect((await (await diffResponse).json()).data).toHaveLength(1) + await expect(page.getByRole("tab", { selected: true })).toHaveAccessibleName(/Files Changed/) + + const review = page.locator('[data-component="session-review"]') + await expectAppVisible(review) + const file = review.locator('[data-file="src/review.ts"]') + await expectAppVisible(file) + const trigger = file.getByRole("button", { expanded: false }) + await expect(trigger).toHaveCount(1) + await trigger.click() + await expect(file.getByRole("button", { expanded: true })).toBeVisible() + await expect(file.getByText("export const value = 'after'", { exact: true })).toBeVisible() +} diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts new file mode 100644 index 000000000000..04e6d2cced83 --- /dev/null +++ b/packages/app/e2e/regression/review-open-file.spec.ts @@ -0,0 +1,163 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/ReviewOpenFile" +const projectID = "proj_review_open_file" +const sessionID = "ses_review_open_file" +const title = "Review open file" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + +test.use({ viewport: { width: 1440, height: 900 } }) + +test("opens and searches project files inline", async ({ page }) => { + const searches: { query: string; dirs?: string; limit?: number }[] = [] + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "open-file-project", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [ + { + id: sessionID, + slug: sessionID, + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + vcsDiff: [fileDiff("src/changed.ts")], + fileList: (path) => { + if (path) return [] + return [ + fileNode("README.md"), + { name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false }, + ] + }, + fileContent: (path) => ({ type: "text", content: `contents:${path}` }), + findFiles: (input) => { + searches.push(input) + return input.query === "nested" ? ["src/nested.ts"] : [] + }, + pageMessages: () => ({ items: [] }), + }) + await page.addInitScript( + ({ directory, server, sessionID }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.global.dat:layout", + JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), + ) + localStorage.setItem( + "opencode.global.dat:review-panel-v2", + JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "session", server, sessionId: sessionID }]), + ) + }, + { directory, server, sessionID }, + ) + + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + const panel = page.locator("#review-panel") + const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]') + const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" }) + const contextButton = page.getByRole("button", { name: "View context usage" }) + await contextButton.click() + await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") + await panel.getByRole("button", { name: "Open file" }).click() + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + await expect(sidebarToggle).toBeDisabled() + await expect(sidebar).toBeVisible() + await contextButton.click() + await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") + await expect(sidebar).toBeHidden() + await panel.getByRole("button", { name: "Open file" }).click() + const filter = panel.getByRole("combobox", { name: "Filter files" }) + await expect(filter).toBeFocused() + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible() + + await panel.getByRole("button", { name: "README.md" }).click() + await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "") + await expect(sidebarToggle).toBeEnabled() + await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible() + await expect(sidebar).toHaveCount(0) + + await panel.getByRole("button", { name: "Open file" }).click() + await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0) + await expect(sidebar).toBeVisible() + await filter.fill("nested") + const result = panel.getByRole("option", { name: /nested\.ts/ }) + await expect(result).toBeVisible() + const resultID = await result.getAttribute("id") + expect(resultID).toBeTruthy() + await expect(filter).toHaveAttribute("aria-activedescendant", resultID!) + await filter.press("Enter") + await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") + await expect(sidebarToggle).toBeEnabled() + await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible() + expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 }) + + await panel.getByRole("button", { name: "Open file" }).click() + await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1) + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + await expect(sidebarToggle).toBeDisabled() + await panel.locator("#session-side-panel-review-tab").click() + await expect(sidebarToggle).toBeEnabled() + await panel.getByRole("tab", { name: "Open file" }).click() + await page.keyboard.press("Control+w") + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0) + await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") +}) + +function fileNode(path: string) { + return { + name: path, + path, + absolute: `${directory}/${path}`, + type: "file", + ignored: false, + } +} + +function fileDiff(file: string) { + return { + file, + before: "before\n", + after: "after\n", + additions: 1, + deletions: 1, + status: "modified", + } +} diff --git a/packages/app/e2e/regression/review-state-persistence.spec.ts b/packages/app/e2e/regression/review-state-persistence.spec.ts new file mode 100644 index 000000000000..0d6756201e7e --- /dev/null +++ b/packages/app/e2e/regression/review-state-persistence.spec.ts @@ -0,0 +1,153 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/ReviewStatePersistence" +const projectID = "proj_review_state_persistence" +const sessionA = "ses_review_state_a" +const sessionB = "ses_review_state_b" +const titleA = "Alpha review state" +const titleB = "Beta review state" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + +test.use({ viewport: { width: 1440, height: 900 } }) + +test("restores review mode and selected file per session", async ({ page }) => { + await setup(page) + await page.goto(sessionHref(sessionA)) + await expectSessionTitle(page, titleA) + await page.getByRole("button", { name: "Toggle review" }).click() + + await selectMode(page, "Git changes", "Branch changes") + await selectFile(page, "beta.ts") + + await switchSession(page, titleB) + await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible() + await selectFile(page, "gamma.ts") + + await switchSession(page, titleA) + await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible() + await expectSelectedFile(page, "beta.ts") + await selectMode(page, "Branch changes", "Git changes") + await expectSelectedFile(page, "alpha.ts") + await selectMode(page, "Git changes", "Branch changes") + await expectSelectedFile(page, "beta.ts") + + await page.reload() + await expectSessionTitle(page, titleA) + await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible() + await expectSelectedFile(page, "beta.ts") + + await switchSession(page, titleB) + await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible() + await expectSelectedFile(page, "gamma.ts") +}) + +async function selectMode(page: Page, current: string, next: string) { + await page.getByRole("button", { name: current }).click() + await page.getByRole("option", { name: next }).dispatchEvent("click") +} + +async function selectFile(page: Page, file: string) { + await page.getByRole("button", { name: file }).click() + await expectSelectedFile(page, file) +} + +async function expectSelectedFile(page: Page, file: string) { + await expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText(file) +} + +async function switchSession(page: Page, title: string) { + await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click() + await expectSessionTitle(page, title) +} + +async function setup(page: Page) { + await mockOpenCodeServer(page, { + protocol: "v1", + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "review-state-persistence", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], + pageMessages: () => ({ items: [] }), + }) + await page.route(/\/vcs(?:\?.*)?$/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ branch: "feature", default_branch: "dev" }), + }), + ) + await page.route("**/vcs/diff**", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + new URL(route.request().url()).searchParams.get("mode") === "branch" + ? [diff("src/alpha.ts"), diff("src/beta.ts")] + : [diff("src/alpha.ts"), diff("src/gamma.ts")], + ), + }), + ) + await page.addInitScript( + ({ directory, server, sessions }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))), + ) + }, + { directory, server, sessions: [sessionA, sessionB] }, + ) +} + +function session(id: string, title: string, created: number) { + return { + id, + slug: id, + projectID, + directory, + title, + version: "dev", + time: { created, updated: created }, + } +} + +function diff(file: string) { + return { + file, + additions: 1, + deletions: 1, + status: "modified", + patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`, + } +} + +function sessionHref(sessionID: string) { + return `/server/${base64Encode(server)}/session/${sessionID}` +} diff --git a/packages/app/e2e/regression/review-tab-switch.spec.ts b/packages/app/e2e/regression/review-tab-switch.spec.ts new file mode 100644 index 000000000000..8634166e5128 --- /dev/null +++ b/packages/app/e2e/regression/review-tab-switch.spec.ts @@ -0,0 +1,147 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible, expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/ReviewTabSwitch" +const projectID = "proj_review_tab_switch" +const sessionA = "ses_review_tab_a" +const sessionB = "ses_review_tab_b" +const titleA = "Alpha session" +const titleB = "Beta session" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` +const diffs = Array.from({ length: 2_740 }, (_, index) => + fileDiff(`src/generated-${String(index).padStart(4, "0")}.ts`), +) +// Marks the review pane DOM node so a remount (fresh node) is detectable. +const PROBE = "original" + +test.use({ viewport: { width: 1440, height: 900 } }) + +// The v2 review pane's diff data is workspace-scoped: switching between session +// tabs in the same workspace must update its parameters reactively instead of +// tearing the pane down and remounting it (which flickers). +test("keeps the v2 review pane mounted when switching session tabs in a workspace", async ({ page }) => { + await setup(page) + + await page.goto(sessionHref(sessionA)) + await expectSessionTitle(page, titleA) + + await page.getByRole("button", { name: "Toggle review" }).click() + const reviewTab = page.locator("#session-side-panel-review-tab") + const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel") + await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel") + await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel") + const review = page.locator('#review-panel [data-component="session-review-v2"]') + await expectAppVisible(review) + await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" })) + await writeProbe(page) + + await switchTab(page, titleB) + await expectSessionTitle(page, titleB) + await expectAppVisible(review) + await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" })) + expect(await readProbe(page)).toBe(PROBE) + + await switchTab(page, titleA) + await expectSessionTitle(page, titleA) + await expectAppVisible(review) + await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" })) + expect(await readProbe(page)).toBe(PROBE) + + const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport') + await viewport.hover() + await page.mouse.wheel(0, 100_000) + await expect + .poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1) + await expect(page.getByRole("button", { name: "generated-2739.ts" })).toBeVisible() +}) + +type Probed = HTMLElement & { __e2eProbe?: string } + +async function switchTab(page: Page, title: string) { + await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click() +} + +async function writeProbe(page: Page) { + await page.locator('#review-panel [data-component="session-review-v2"]').evaluate((el, probe) => { + ;(el as Probed).__e2eProbe = probe + }, PROBE) +} + +async function readProbe(page: Page) { + return page.locator('#review-panel [data-component="session-review-v2"]').evaluate((el) => (el as Probed).__e2eProbe) +} + +async function setup(page: Page) { + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "review-tab-switch", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], + vcsDiff: diffs, + pageMessages: () => ({ items: [] }), + }) + + await page.addInitScript( + ({ directory, server, sessions }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))), + ) + }, + { directory, server, sessions: [sessionA, sessionB] }, + ) +} + +function session(id: string, title: string, created: number) { + return { + id, + slug: id, + projectID, + directory, + title, + version: "dev", + time: { created, updated: created }, + } +} + +function sessionHref(sessionID: string) { + return `/server/${base64Encode(server)}/session/${sessionID}` +} + +function fileDiff(file: string) { + return { + file, + additions: 1, + deletions: 1, + status: "modified", + patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`, + } +} diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts new file mode 100644 index 000000000000..79b564820e20 --- /dev/null +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -0,0 +1,306 @@ +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/ReviewTerminalStacked" +const projectID = "proj_review_terminal_stacked" +const sessionID = "ses_review_terminal_stacked" +const title = "Review terminal stacked" +const branchDiffs = [ + fileDiff(".github/actions/setup-bun/action.yml", 7), + ...Array.from({ length: 2_739 }, (_, index) => + fileDiff( + `src/branch/d${String(Math.floor(index / 100)).padStart(5, "0")}/generated-${String(index).padStart(4, "0")}.ts`, + 100, + false, + ), + ), +] + +test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => { + test.setTimeout(120_000) + const events: Array<{ directory: string; payload: Record }> = [] + const sessionStatus = { [sessionID]: { type: "idle" as "busy" | "idle" } } + let detailVersion = 1 + let detailFailures = 1 + await page.setViewportSize({ width: 1400, height: 900 }) + await mockOpenCodeServer(page, { + protocol: "v1", + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "review-terminal-stacked", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [ + { + id: sessionID, + slug: "review-terminal-stacked", + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + sessionStatus: () => sessionStatus, + pageMessages: () => ({ items: [] }), + events: () => events.splice(0, 1), + eventRetry: 16, + }) + await page.route(/\/vcs(?:\?.*)?$/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + branch: "review-pane-performance", + default_branch: "dev", + }), + }), + ) + await page.route("**/vcs/diff**", (route) => { + const url = new URL(route.request().url()) + const scope = url.searchParams.get("directory")?.replaceAll("\\", "/") + const detail = scope?.endsWith("/src/branch/d00027") + if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" }) + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + url.searchParams.get("mode") === "branch" + ? detail + ? branchDiffs + .filter((diff) => diff.file.startsWith("src/branch/d00027/")) + .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion)) + : branchDiffs + : Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)), + ), + }) + }) + await page.route("**/pty*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory } }, + data: { + id: "pty_review_terminal", + title: "Terminal 1", + command: "cmd.exe", + args: [], + cwd: directory, + status: "running", + pid: 1, + }, + }), + }), + ) + await page.route("**/pty/pty_review_terminal*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory } }, + data: { + id: "pty_review_terminal", + title: "Terminal 1", + command: "cmd.exe", + args: [], + cwd: directory, + status: "running", + pid: 1, + }, + }), + }), + ) + await page.route("**/pty/pty_review_terminal/connect-token*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory } }, + data: { ticket: "e2e-ticket", expires_in: 60 }, + }), + }), + ) + await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined) + await page.addInitScript(() => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:layout", + JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), + ) + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + await expect(page.locator("#review-panel")).toBeVisible() + await expectTree(page, 8, "git-0.ts") + + await selectMode(page, "Git changes", "Branch changes") + await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740") + await page.keyboard.press("Control+Backquote") + await expect(page.locator("#terminal-panel")).toBeVisible() + await expectTree(page, 2_773, "action.yml") + await expectStackGeometry(page) + + const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport') + await treeViewport.hover() + await page.mouse.wheel(0, 100_000) + await expect + .poll(() => treeViewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1) + const lastFile = page.getByRole("button", { name: "generated-2738.ts" }) + await expect(lastFile).toBeVisible() + const bottomGap = await lastFile.evaluate((element) => { + const viewport = element.closest(".scroll-view__viewport")!.getBoundingClientRect() + return viewport.bottom - element.getBoundingClientRect().bottom + }) + expect(bottomGap).toBeGreaterThanOrEqual(0) + expect(bottomGap).toBeLessThanOrEqual(16) + const lazyDiff = page.waitForRequest((request) => { + const url = new URL(request.url()) + return ( + url.pathname === "/vcs/diff" && + url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true + ) + }) + await lastFile.click() + await lazyDiff + const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]') + await expect(preview).toContainText("after-1") + detailVersion = 2 + sessionStatus[sessionID] = { type: "busy" } + events.push(statusEvent("busy")) + await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() + const refreshedDiff = page.waitForRequest((request) => { + const url = new URL(request.url()) + return ( + url.pathname === "/vcs/diff" && + url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true + ) + }) + sessionStatus[sessionID] = { type: "idle" } + events.push(statusEvent("idle")) + await refreshedDiff + await expect(preview).toContainText("after-2") + await selectMode(page, "Branch changes", "Git changes") + await expectTree(page, 8, "git-0.ts") + await page.getByRole("button", { name: "git-0.ts" }).click() + await selectMode(page, "Git changes", "Branch changes") + await expectTree(page, 2_773, "action.yml") + + const filter = page.getByRole("searchbox", { name: "Filter files" }) + await filter.fill("generated-2738") + await expectTree(page, 1, "generated-2738.ts") + await filter.fill("") + await expectTree(page, 2_773, "action.yml") + + await page.getByRole("button", { name: "Toggle file tree" }).click() + await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0) + await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0) + await page.getByRole("button", { name: "Toggle file tree" }).click() + await expectTree(page, 2_773, "action.yml") + + await page.keyboard.press("Control+Backquote") + await expect(page.locator("#terminal-panel")).toHaveCount(0) + await expectTree(page, 2_773, "action.yml") + await page.keyboard.press("Control+Backquote") + await expect(page.locator("#terminal-panel")).toBeVisible() + await expectTree(page, 2_773, "action.yml") + + await page.getByRole("button", { name: "Toggle review" }).click() + await expect(page.locator("#review-panel")).toHaveCount(0) + await page.getByRole("button", { name: "Toggle review" }).click() + await expectTree(page, 2_773, "action.yml") + await page.setViewportSize({ width: 1_000, height: 700 }) + await expectTree(page, 2_773, "action.yml") + await expectStackGeometry(page) + await page.setViewportSize({ width: 1_000, height: 120 }) + await page.setViewportSize({ width: 1_400, height: 900 }) + await expectTree(page, 2_773, "action.yml") + await expectStackGeometry(page) +}) + +async function selectMode(page: Page, current: string, next: string) { + await page.getByRole("button", { name: current }).click() + const option = page.getByRole("option", { name: next }) + await expect(option).toBeVisible() + await option.click() +} + +async function expectTree(page: Page, total: number, file: string) { + await expectMountedTree(page, total) + await expect(page.getByRole("button", { name: file })).toBeVisible() +} + +async function expectMountedTree(page: Page, total: number) { + const tree = page.locator('#review-panel [data-component="file-tree-v2"]') + await expect(tree).toHaveAttribute("data-total-rows", String(total)) + await expect + .poll(() => tree.evaluate((element) => element.querySelectorAll('[data-slot="file-tree-v2-row"]').length)) + .toBeGreaterThan(0) + const state = await tree.evaluate((element) => ({ + root: element.getBoundingClientRect().height, + viewport: element.closest(".scroll-view__viewport")!.getBoundingClientRect().height, + rows: element.querySelectorAll('[data-slot="file-tree-v2-row"]').length, + })) + expect(state.viewport).toBeGreaterThan(0) + expect(state.root).toBeGreaterThan(0) + expect(state.rows).toBeGreaterThan(0) + expect(state.rows).toBeLessThanOrEqual(60) +} + +async function expectStackGeometry(page: Page) { + const geometry = await page.evaluate(() => { + const review = document.querySelector("#review-panel")! + const terminal = document.querySelector("#terminal-panel")! + const reviewParent = review.parentElement!.getBoundingClientRect() + const terminalParent = terminal.parentElement!.getBoundingClientRect() + return { + review: review.getBoundingClientRect().height, + reviewParent: reviewParent.height, + terminal: terminal.getBoundingClientRect().height, + terminalParent: terminalParent.height, + } + }) + expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1) + expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1) +} + +function base64Encode(value: string) { + return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") +} + +function statusEvent(type: "busy" | "idle") { + return { + directory, + payload: { type: "session.status", properties: { sessionID, status: { type } } }, + } +} + +function fileDiff(file: string, additions: number, loaded = true, version = 1) { + return { + file, + additions, + deletions: 0, + status: "modified", + patch: loaded + ? `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after-${version}'\n` + : `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}`, + } +} diff --git a/packages/app/e2e/regression/session-list-path-loading.spec.ts b/packages/app/e2e/regression/session-list-path-loading.spec.ts index 4a3855122a40..3319514df648 100644 --- a/packages/app/e2e/regression/session-list-path-loading.spec.ts +++ b/packages/app/e2e/regression/session-list-path-loading.spec.ts @@ -16,8 +16,8 @@ test("shows loaded sessions before the directory path request resolves", async ( const pathBlocked = new Promise((resolve) => { releasePath = resolve }) - await page.route("**/path?*", async (route) => { - if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback() + await page.route("**/api/path?*", async (route) => { + if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback() await pathBlocked return route.fallback() }) diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts new file mode 100644 index 000000000000..5ea9d4f7613b --- /dev/null +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -0,0 +1,221 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { installSseTransport } from "../utils/sse-transport" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/RequestDocks" +const projectID = "proj_request_docks" +const sessionID = "ses_request_docks" +const title = "Request dock regression" + +test("shows a pending question dock", async ({ page }) => { + await mockServer(page, { + questions: [ + { + id: "question-request", + sessionID, + questions: [ + { + header: "Implementation", + question: "Which implementation should be used?", + options: [ + { label: "Minimal", description: "Use the smallest correct change" }, + { label: "Extended", description: "Include additional behavior" }, + ], + }, + ], + }, + ], + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + const question = page.locator('[data-component="dock-prompt"][data-kind="question"]') + await expect(question).toBeVisible() + await expect(question.getByText("Which implementation should be used?")).toBeVisible() + await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible() + await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible() + await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0) + + const rejectRequests: string[] = [] + page.on("request", (request) => { + if (request.method() !== "POST") return + if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`) + rejectRequests.push(request.url()) + }) + + await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click() + await expect(question).toBeVisible() + await expect(question.getByText("Which implementation should be used?")).toBeVisible() + await expect(question.getByText("Select one answer")).toBeHidden() + await expect(question.getByRole("radio", { name: /Minimal/ })).toBeHidden() + await expect(question.getByRole("radio", { name: /Extended/ })).toBeHidden() + await expect(question.getByRole("button", { name: "Dismiss" })).toBeVisible() + await expect(question.getByRole("button", { name: "Submit" })).toBeVisible() + await expect(page.locator('[data-component="question-minimized-dock"]')).toHaveCount(0) + expect(rejectRequests).toEqual([]) + + await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click() + await expect(question).toBeVisible() + await expect(question.getByText("Which implementation should be used?")).toBeVisible() + await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible() + expect(rejectRequests).toEqual([]) + + await question.getByRole("radio", { name: /Minimal/ }).click() + const reply = page.waitForRequest( + (request) => + request.method() === "POST" && + new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`, + ) + await question.getByRole("button", { name: "Submit" }).click() + expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] }) +}) + +test("shows a pending permission dock", async ({ page }) => { + await mockServer(page, { + permissions: [ + { + id: "permission-request", + sessionID, + permission: "bash", + patterns: ["git status", "git diff"], + metadata: {}, + always: [], + }, + ], + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + const permission = page.locator('[data-component="dock-prompt"][data-kind="permission"]') + await expect(permission).toBeVisible() + await expect(permission.getByText("git status")).toBeVisible() + await expect(permission.getByText("git diff")).toBeVisible() + await expect(permission.locator('[data-slot="permission-footer-actions"] button')).toHaveCount(3) + await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0) + + const reply = page.waitForRequest((request) => request.method() === "POST") + await permission.getByRole("button", { name: "Allow once" }).click() + const request = await reply + expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`) + expect(request.postDataJSON()).toEqual({ reply: "once" }) +}) + +test("restores the draft caret before typing after a request dock closes", async ({ page }) => { + const transport = await installSseTransport(page, { + server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, + retry: 20, + }) + await mockServer(page, { questions: [] }) + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await transport.waitForConnection() + await expectSessionTitle(page, title) + + const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]') + const draft = "keep the caret at the end" + await editor.fill(draft) + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))) + for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft") + const cursor = draft.length - 4 + await expect + .poll(() => + editor.evaluate((element) => { + const selection = window.getSelection() + if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1 + const range = selection.getRangeAt(0).cloneRange() + range.selectNodeContents(element) + range.setEnd(selection.anchorNode!, selection.anchorOffset) + return range.toString().length + }), + ) + .toBe(cursor) + await transport.send({ + directory, + payload: { + type: "question.asked", + properties: { + id: "question-caret", + sessionID, + questions: [ + { + header: "Continue", + question: "Continue?", + options: [{ label: "Yes", description: "Continue the session" }], + }, + ], + tool: { messageID: "message-caret", callID: "call-caret" }, + }, + }, + }) + const question = page.locator('[data-component="dock-prompt"][data-kind="question"]') + await expect(question).toBeVisible() + await expect(editor).toHaveCount(0) + + await transport.send({ + directory, + payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } }, + }) + await expect(question).toHaveCount(0) + await expect(editor).toBeVisible() + await page.keyboard.press("x") + + await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`) +}) + +async function mockServer( + page: Page, + requests: { + permissions?: unknown[] | (() => unknown[]) + questions?: unknown[] | (() => unknown[]) + }, +) { + await mockOpenCodeServer(page, { + protocol: "v2", + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "request-docks", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { + "claude-opus-4-6": { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + limit: { context: 200_000 }, + }, + }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "claude-opus-4-6" }, + }, + sessions: [ + { + id: sessionID, + slug: "request-docks", + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + pageMessages: () => ({ items: [] }), + permissions: requests.permissions, + questions: requests.questions, + }) + await page.addInitScript(() => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + }) +} diff --git a/packages/app/e2e/regression/session-timeline-accessibility.spec.ts b/packages/app/e2e/regression/session-timeline-accessibility.spec.ts new file mode 100644 index 000000000000..598763c02253 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-accessibility.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from "@playwright/test" +import { assistantMessage, setupTimeline, shell, userMessage } from "../performance/timeline-stability/fixture" + +test("space activates a focused timeline button instead of scrolling", async ({ page }) => { + const shellID = "prt_space_button_shell" + await setupTimeline(page, { + messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])], + settings: { shellToolPartsExpanded: false }, + reducedMotion: true, + }) + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`) + await trigger.focus() + const before = await scroller.evaluate((element) => element.scrollTop) + await trigger.press("Space") + await expect(trigger).toHaveAttribute("aria-expanded", "true") + expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before) +}) + +function lines(count: number) { + return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n") +} diff --git a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts index 8b31b8cc273c..5b6e0b127b17 100644 --- a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts @@ -147,7 +147,8 @@ test.describe("regression: session timeline local row state", () => { const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first() await expectAppVisible(wrapper) - await expectAppVisible(wrapper.locator('[data-component="file"][data-mode="diff"]').first()) + const file = wrapper.locator('[data-component="file"][data-mode="diff"]').first() + await expectAppVisible(file) await markDiffProbe(page) events.push({ @@ -159,7 +160,15 @@ test.describe("regression: session timeline local row state", () => { }) await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 }) - expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" }) + const siblingProbe = await readDiffProbe(page) + expect(siblingProbe).toEqual({ + fileMarker: "before", + frameMarker: "before", + rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`, + rowMarker: "before", + shadowRoots: 0, + toolMarker: "before", + }) await markDiffProbe(page) events.push({ @@ -173,7 +182,73 @@ test.describe("regression: session timeline local row state", () => { await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible( { timeout: 10_000 }, ) - expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" }) + expect(await readDiffProbe(page)).toEqual({ + fileMarker: "before", + frameMarker: "before", + rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`, + rowMarker: "before", + shadowRoots: 0, + toolMarker: "before", + }) + }) + + test("keeps a sticky edit header aligned with a multi-hunk diff", async ({ page }) => { + const events: EventPayload[] = [] + const lines = Array.from({ length: 1_000 }, (_, index) => `export const value${index} = ${index}\n`).join("") + const after = [100, 300, 500, 700, 900].reduce( + (result, index) => + result.replace(`export const value${index} = ${index}`, `export const value${index} = compute(${index})`), + lines, + ) + const part = { + ...editPart, + state: { + ...editPart.state, + metadata: { + ...editPart.state.metadata, + filediff: { + file: "src/regression.ts", + additions: 1, + deletions: 1, + before: lines, + after, + }, + }, + }, + } + await mockServer(page, events, [userMessage, { ...assistantMessage, parts: [part] }]) + await configurePage(page) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first() + const trigger = wrapper.locator('[data-slot="collapsible-trigger"]').first() + const diff = wrapper.locator('[data-component="edit-content"]').first() + await expectAppVisible(diff) + await expect.poll(() => wrapper.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(500) + const samples = await wrapper.evaluate(async (element) => { + const root = element.closest(".scroll-view__viewport")! + element.scrollIntoView({ block: "start" }) + const result = [] + for (const offset of [0, 120, 240, 360, 480]) { + root.scrollBy(0, offset - (result.at(-1)?.offset ?? 0)) + await new Promise(requestAnimationFrame) + const trigger = element.querySelector('[data-slot="collapsible-trigger"]')! + const diff = element.querySelector('[data-component="edit-content"]')! + result.push({ + offset, + trigger: trigger.getBoundingClientRect().y, + diff: diff.getBoundingClientRect().y, + bottom: element.getBoundingClientRect().bottom, + }) + } + return result + }) + + expect(samples[0]!.trigger).toBeLessThan(samples[0]!.diff) + expect(samples.every((sample) => Math.abs(sample.trigger - samples[0]!.trigger) <= 1)).toBe(true) + expect(samples.every((sample) => sample.trigger < sample.bottom)).toBe(true) }) }) @@ -186,7 +261,6 @@ async function configurePage(page: Page) { editToolPartsExpanded: true, shellToolPartsExpanded: true, showReasoningSummaries: true, - showSessionProgressBar: true, }, }), ) @@ -247,10 +321,16 @@ async function markDiffProbe(page: Page) { .evaluate((element) => { const tool = element as HTMLElement const file = tool.querySelector('[data-component="file"][data-mode="diff"]') + const row = tool.closest("[data-timeline-key]") + const frame = tool.closest("[data-timeline-row]") if (!file) throw new Error("missing edit diff file") + if (!row) throw new Error("missing virtual timeline row") + if (!frame) throw new Error("missing timeline row frame") tool.dataset.timelineProbe = "before" file.dataset.timelineProbe = "before" + row.dataset.timelineProbe = "before" + frame.dataset.timelineProbe = "before" window.__timelineDiffProbe.reset() }) } @@ -262,10 +342,15 @@ async function readDiffProbe(page: Page) { .evaluate((element) => { const tool = element as HTMLElement const file = tool.querySelector('[data-component="file"][data-mode="diff"]') + const row = tool.closest("[data-timeline-key]") + const frame = tool.closest("[data-timeline-row]") return { fileMarker: file?.dataset.timelineProbe, shadowRoots: window.__timelineDiffProbe.shadowRoots(), toolMarker: tool.dataset.timelineProbe, + rowMarker: row?.dataset.timelineProbe, + rowKey: row?.dataset.timelineKey, + frameMarker: frame?.dataset.timelineProbe, } }) } @@ -300,14 +385,15 @@ function readExpanded(element: Element) { return !!content && content.getBoundingClientRect().height > 0 } -async function mockServer(page: Page, events: EventPayload[]) { +async function mockServer(page: Page, events: EventPayload[], messages = [userMessage, assistantMessage]) { await mockOpenCodeServer(page, { directory, project: project(), provider: provider(), sessions: [session()], - pageMessages: () => ({ items: [userMessage, assistantMessage] }), - events: () => events.splice(0), + pageMessages: () => ({ items: messages }), + events: () => events.splice(0, 1), + eventRetry: 16, }) } diff --git a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts index d2b182757349..a9a4738da928 100644 --- a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts +++ b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts @@ -1,6 +1,13 @@ import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible, expectSessionTitle } from "../utils/waits" +import { + analyzeVisualObservations, + defineVisualRegions, + startVisualProbe, + stopVisualProbe, + visualPlan, +} from "../utils/visual-stability" const directory = "C:/OpenCode/ContextResizeRegression" const projectID = "proj_context_resize_regression" @@ -32,12 +39,86 @@ test.describe("regression: session timeline context group resize", () => { const samples = await sampleExpansion(page) const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5) - console.log("context resize samples", JSON.stringify(samples, null, 2)) - expect(samples[0]?.overlap).toBe(0) expect(visibleOverlap).toEqual([]) expect(samples.at(-1)?.expanded).toBe("true") }) + + test("paints a stable exploring to explored transition", async ({ page }) => { + const events: { directory: string; payload: Record }[] = [] + await page.setViewportSize({ width: 1400, height: 900 }) + await mockServer(page, events, [ + ...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(), + ...turn(10, true, "running"), + ]) + await configurePage(page) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + const devtools = await page.context().newCDPSession(page) + await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 }) + const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first() + await expectAppVisible(context) + await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Exploring") + + const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]` + const regions = defineVisualRegions({ + status: { + selector: `${contextSelector} [data-component="tool-status-title"]`, + opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'], + }, + context: { selector: contextSelector, closest: '[data-timeline-row="AssistantPart"]' }, + following: { + selector: `[data-timeline-part-id="${followingTextID}"]`, + closest: '[data-timeline-row="AssistantPart"]', + }, + }) + await startVisualProbe(page, regions) + for (const [index, delay] of [120, 350, 80, 500].entries()) { + events.push({ + directory, + payload: { + type: "message.part.updated", + properties: { + part: contextTool( + contextIDs[index]!, + id("msg_assistant", 10), + ["read", "glob", "grep", "list"][index]!, + [ + { filePath: "src/recent-a.ts" }, + { path: directory, pattern: "**/*.ts" }, + { path: directory, pattern: "Explored" }, + { path: "src" }, + ][index]!, + ), + }, + }, + }) + await page.waitForTimeout(delay) + } + + await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored") + await page.waitForTimeout(700) + const trace = await stopVisualProbe(page) + const labels = trace.samples + .map((sample) => sample.regions.status?.label) + .filter((value): value is string => !!value) + .filter((value, index, all) => value !== all[index - 1]) + const issues = analyzeVisualObservations( + trace.samples, + visualPlan(regions, [ + { type: "required", regions: ["context", "following"] }, + { type: "opacity", regions: "all" }, + { type: "continuity", regions: "all" }, + { type: "motion", regions: "all" }, + { type: "label-stability", regions: "all" }, + { type: "flow", regions: ["context", "following"] }, + ]), + ) + + expect(labels).toEqual(["Exploring", "Explored"]) + expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([]) + }) }) async function configurePage(page: Page) { @@ -49,7 +130,6 @@ async function configurePage(page: Page) { editToolPartsExpanded: true, shellToolPartsExpanded: true, showReasoningSummaries: true, - showSessionProgressBar: true, }, }), ) @@ -115,13 +195,15 @@ async function sampleExpansion(page: Page) { let frame = 1 const tick = () => { - capture(frame, "raf") - frame += 1 - if (frame > 8) { - resolve(samples) - return - } - requestAnimationFrame(tick) + setTimeout(() => { + capture(frame, "painted") + frame += 1 + if (frame > 8) { + resolve(samples) + return + } + requestAnimationFrame(tick) + }, 0) } requestAnimationFrame(tick) }), @@ -129,7 +211,7 @@ async function sampleExpansion(page: Page) { ) } -function turn(index: number, target: boolean): Message[] { +function turn(index: number, target: boolean, status: "running" | "completed" = "completed"): Message[] { const userID = id("msg_user", index) const assistantID = id("msg_assistant", index) return [ @@ -164,10 +246,22 @@ function turn(index: number, target: boolean): Message[] { }, parts: target ? [ - contextTool(contextIDs[0]!, assistantID, "read", { filePath: "src/recent-a.ts", offset: 0, limit: 120 }), - contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }), - contextTool(contextIDs[2]!, assistantID, "grep", { path: directory, pattern: "Explored", include: "*.ts" }), - contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }), + contextTool( + contextIDs[0]!, + assistantID, + "read", + { filePath: "src/recent-a.ts", offset: 0, limit: 120 }, + status, + ), + contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status), + contextTool( + contextIDs[2]!, + assistantID, + "grep", + { path: directory, pattern: "Explored", include: "*.ts" }, + status, + ), + contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status), { id: followingTextID, sessionID, @@ -189,7 +283,13 @@ function turn(index: number, target: boolean): Message[] { ] } -function contextTool(partID: string, messageID: string, tool: string, input: Record) { +function contextTool( + partID: string, + messageID: string, + tool: string, + input: Record, + status: "running" | "completed" = "completed", +) { return { id: partID, sessionID, @@ -198,7 +298,7 @@ function contextTool(partID: string, messageID: string, tool: string, input: Rec callID: `call_${partID}`, tool, state: { - status: "completed", + status, input, output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`, title: input.filePath || input.path || input.pattern || "completed", @@ -208,13 +308,19 @@ function contextTool(partID: string, messageID: string, tool: string, input: Rec } } -async function mockServer(page: Page) { +async function mockServer( + page: Page, + events: { directory: string; payload: Record }[] = [], + fixtureMessages = messages, +) { await mockOpenCodeServer(page, { directory, project: project(), provider: provider(), sessions: [session()], - pageMessages: () => ({ items: messages }), + pageMessages: () => ({ items: fixtureMessages }), + events: () => events.splice(0, 1), + eventRetry: 50, }) } diff --git a/packages/app/e2e/regression/session-timeline-context-state.spec.ts b/packages/app/e2e/regression/session-timeline-context-state.spec.ts new file mode 100644 index 000000000000..37878325e14d --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-context-state.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from "@playwright/test" +import { + assistantMessage, + partUpdated, + setupTimeline, + toolPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +test("preserves a collapsed context group through count and status updates", async ({ page }) => { + const ids = ["prt_closed_01_read", "prt_closed_02_glob"] + const inputs = { + read: { filePath: "src/a.ts", offset: 0, limit: 120 }, + glob: { path: ".", pattern: "**/*.ts" }, + } + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage( + [toolPart(ids[0]!, "read", "running", inputs.read), toolPart(ids[1]!, "glob", "running", inputs.glob)], + { completed: false }, + ), + ], + }) + const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`) + const trigger = group.locator('[data-slot="collapsible-trigger"]') + await expect(trigger).toHaveAttribute("aria-expanded", "false") + await timeline.send(partUpdated(toolPart(ids[0]!, "read", "completed", inputs.read)), 100) + await timeline.send(partUpdated(toolPart(ids[1]!, "glob", "completed", inputs.glob)), 300) + await expect(trigger).toHaveAttribute("aria-expanded", "false") +}) diff --git a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts new file mode 100644 index 000000000000..f07da121c666 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from "@playwright/test" +import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture" + +test("renders completed write content", async ({ page }) => { + const id = "prt_file_projection_write" + await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + toolPart(id, "write", "completed", { filePath: "src/write.ts", content: "export const written = true\n" }), + ]), + ], + settings: { editToolPartsExpanded: true }, + }) + + await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="write-content"]`)).toBeVisible() +}) + +test("renders a completed single-file patch", async ({ page }) => { + const id = "prt_file_projection_single_patch" + await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + toolPart( + id, + "apply_patch", + "completed", + { files: ["src/a.ts"] }, + { + metadata: { + files: [ + { + filePath: "src/a.ts", + relativePath: "src/a.ts", + type: "update", + additions: 1, + deletions: 1, + before: "export const value = 1\n", + after: "export const value = 2\n", + }, + ], + }, + }, + ), + ]), + ], + settings: { editToolPartsExpanded: true }, + }) + + await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="apply-patch-file-diff"]`)).toBeVisible() +}) diff --git a/packages/app/e2e/regression/session-timeline-file-state.spec.ts b/packages/app/e2e/regression/session-timeline-file-state.spec.ts new file mode 100644 index 000000000000..cb228c13c7af --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-file-state.spec.ts @@ -0,0 +1,94 @@ +import { expect, test } from "@playwright/test" +import { + assistantMessage, + partUpdated, + setupTimeline, + toolPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +test("updates edit diagnostics without resetting manual collapse state", async ({ page }) => { + const editID = "prt_diagnostics_edit" + const base = editPart(editID, []) + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage([base])], + settings: { editToolPartsExpanded: true }, + }) + const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first() + await trigger.click() + await expect(trigger).toHaveAttribute("aria-expanded", "false") + await timeline.send( + partUpdated(editPart(editID, [diagnostic("First failure", 2), diagnostic("Second failure", 4)])), + 300, + ) + await expect(trigger).toHaveAttribute("aria-expanded", "false") + await timeline.send(partUpdated(editPart(editID, [])), 300) + await expect(trigger).toHaveAttribute("aria-expanded", "false") +}) + +test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => { + const patchID = "prt_nested_patch" + const files = [patchFile("src/a.ts", "update"), patchFile("src/b.ts", "add"), patchFile("src/old.ts", "delete")] + await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + toolPart( + patchID, + "apply_patch", + "completed", + { files: files.map((file) => file.filePath) }, + { metadata: { files } }, + ), + ]), + ], + settings: { editToolPartsExpanded: true }, + }) + const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`) + const outer = wrapper.locator('[data-slot="collapsible-trigger"]').first() + const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"]') + await deleted.getByRole("button").click() + await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true") + await outer.click() + await expect(outer).toHaveAttribute("aria-expanded", "false") + await outer.click() + await expect(outer).toHaveAttribute("aria-expanded", "true") + await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true") +}) + +function patchFile(filePath: string, type: "add" | "update" | "delete") { + return { + filePath, + relativePath: filePath, + type, + additions: type === "delete" ? 0 : 4, + deletions: type === "add" ? 0 : 3, + before: type === "add" ? undefined : source(false), + after: type === "delete" ? undefined : source(true), + } +} + +function editPart(id: string, diagnostics: Record[]) { + return toolPart( + id, + "edit", + "completed", + { filePath: "src/edit.ts" }, + { + metadata: { + filediff: { file: "src/edit.ts", additions: 1, deletions: 1, before: source(false), after: source(true) }, + diagnostics, + }, + }, + ) +} + +function diagnostic(message: string, line: number) { + return { message, severity: 1, range: { start: { line, character: 0 }, end: { line, character: 2 } } } +} + +function source(changed: boolean) { + return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join( + "", + ) +} diff --git a/packages/app/e2e/regression/session-timeline-history-root.spec.ts b/packages/app/e2e/regression/session-timeline-history-root.spec.ts new file mode 100644 index 000000000000..e5ef7998ea7f --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-history-root.spec.ts @@ -0,0 +1,242 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { + assistantMessage, + directory, + messageUpdated, + project, + session, + sessionID, + status, + textPart, + title, + userID, + userMessage, +} from "../performance/timeline-stability/fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { installSseTransport } from "../utils/sse-transport" +import { expectSessionTitle } from "../utils/waits" + +const initialPageSize = 20 +const historyPageSize = 200 +const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) => + assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], { + id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, + parentID: userID, + created: 1700000001000 + index * 1_000, + completed: index < initialPageSize, + }), +) +const messages = [userMessage(), ...assistants] +const lastAssistant = assistants.at(-1)! +const lastPartID = assistants.at(-1)!.parts[0]!.id +const userPartID = `prt_${userID}_text` +const completed = { + ...lastAssistant.info, + time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 }, +} +const scenarios = [ + { name: "completion", info: completed, idleFirst: false, interrupted: false }, + { + name: "interruption", + info: { ...completed, error: { name: "MessageAbortedError", data: { message: "Stopped" } } }, + idleFirst: true, + interrupted: true, + }, +] as const + +test.use({ viewport: { width: 646, height: 1385 } }) + +for (const scenario of scenarios) { + test(`keeps visible timeline content visible through ${scenario.name}`, async ({ page }) => { + const requests: { before?: string; phase: "start" | "end" }[] = [] + const pages: { before?: string; limit: number }[] = [] + const roots: { sessionID: string; messageID: string }[] = [] + const sequence: string[] = [] + const history = Promise.withResolvers() + const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { + server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, + retry: 20, + }) + await mockOpenCodeServer(page, { + directory, + project: project(), + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { + "claude-opus-4-6": { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + limit: { context: 200_000 }, + }, + }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "claude-opus-4-6" }, + }, + sessions: [session()], + sessionStatus: { [sessionID]: { type: "busy" } }, + beforeMessagesResponse: (request) => (request.before ? history.promise : Promise.resolve()), + onMessages: (request) => { + requests.push(request) + sequence.push(`messages:${request.phase}:${request.before ?? "latest"}`) + }, + onMessage: (request) => { + roots.push(request) + sequence.push(`message:${request.messageID}`) + }, + message: (requestedSessionID, messageID) => { + if (requestedSessionID !== sessionID) return + return messages.find((item) => item.info.id === messageID) + }, + pageMessages: (_, limit, before) => { + pages.push({ before, limit }) + const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length + const start = Math.max(0, end - limit) + return { + items: messages.slice(start, end), + cursor: start > 0 ? messages[start]!.info.id : undefined, + } + }, + }) + await page.addInitScript(() => { + const visibleParts = () => { + const virtual = document.querySelector("[data-timeline-virtual-content]") + const viewport = virtual?.closest(".scroll-view__viewport") + const view = viewport?.getBoundingClientRect() + if (!viewport || !view) return [] + return [...viewport.querySelectorAll("[data-timeline-part-id]")] + .filter((part) => { + const rect = part.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom + }) + .flatMap((part) => (part.dataset.timelinePartId ? [part.dataset.timelinePartId] : [])) + } + const state = { + armed: false, + hidden: false, + visibleParts: [] as string[], + samples: 0, + stop: false, + arm() { + state.visibleParts = visibleParts() + state.armed = true + }, + } + ;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state + const sample = () => { + if (state.armed) { + const virtual = document.querySelector("[data-timeline-virtual-content]") + const viewport = virtual?.closest(".scroll-view__viewport") + const view = viewport?.getBoundingClientRect() + const visible = (partID: string) => { + const part = viewport?.querySelector(`[data-timeline-part-id="${CSS.escape(partID)}"]`) + const rect = part?.getBoundingClientRect() + return ( + !!rect && !!view && rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom + ) + } + if (!virtual || state.visibleParts.length === 0 || state.visibleParts.some((partID) => !visible(partID))) + state.hidden = true + state.samples++ + } + if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0)) + } + requestAnimationFrame(() => setTimeout(sample, 0)) + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await transport.waitForConnection() + await expectSessionTitle(page, title) + await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible() + await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible() + await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2) + expect(requests.filter((request) => request.phase === "end")).toHaveLength(1) + expect(sequence.slice(0, 4)).toEqual([ + "messages:start:latest", + "messages:end:latest", + `message:${userID}`, + `messages:start:${messages.at(-initialPageSize)!.info.id}`, + ]) + await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize) + await page.evaluate(() => { + ;( + window as Window & { + __historyRootProbe?: { arm(): void } + } + ).__historyRootProbe!.arm() + }) + await waitForProbeSamples(page, 0) + expect(await visibleContentHidden(page)).toBe(false) + const beforeHistory = await probeSamples(page) + history.resolve() + await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length) + await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2) + await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() + await waitForProbeSamples(page, beforeHistory) + expect(pages).toEqual([ + { before: undefined, limit: initialPageSize }, + { before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize }, + ]) + expect(roots).toEqual([{ sessionID, messageID: userID }]) + + const message = messageUpdated(scenario.info) + const idle = status("idle") + for (const event of scenario.idleFirst ? [idle, message] : [message, idle]) { + const beforeEvent = await probeSamples(page) + await transport.send(event) + if (event === idle) await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0) + if (event === message && scenario.interrupted) + await expect(page.getByText("Interrupted", { exact: true })).toBeVisible() + await waitForProbeSamples(page, beforeEvent) + const current = await timelineState(page) + expect(current, JSON.stringify(current)).toMatchObject({ virtual: true }) + expect(current.rows, JSON.stringify(current)).toBeGreaterThan(0) + } + + expect(requests[0]).toEqual({ before: undefined, phase: "start", sessionID }) + expect(requests[1]).toEqual({ before: undefined, phase: "end", sessionID }) + await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0) + await expect(page.locator('[data-timeline-row="bottom-spacer"]')).toBeVisible() + if (scenario.interrupted) await expect(page.getByText("Interrupted", { exact: true })).toBeVisible() + expect( + await page.evaluate(() => { + const state = (window as Window & { __historyRootProbe?: { hidden: boolean; stop: boolean } }) + .__historyRootProbe! + state.stop = true + return state.hidden + }), + ).toBe(false) + }) +} + +function timelineState(page: Page) { + return page.evaluate(() => ({ + virtual: !!document.querySelector("[data-timeline-virtual-content]"), + rows: document.querySelectorAll("[data-timeline-key]").length, + })) +} + +function probeSamples(page: Page) { + return page.evaluate( + () => (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples, + ) +} + +async function waitForProbeSamples(page: Page, after: number) { + await page.waitForFunction( + (after) => + (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples >= after + 3, + after, + ) +} + +function visibleContentHidden(page: Page) { + return page.evaluate( + () => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden, + ) +} diff --git a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts new file mode 100644 index 000000000000..b303071c87f7 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts @@ -0,0 +1,111 @@ +import { expect, test } from "@playwright/test" +import { + assistantMessage, + completedAssistantInfo, + messageUpdated, + partUpdated, + reasoningPart, + setupTimeline, + shell, + status, + textPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +for (const expanded of [false, true]) { + test(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ page }) => { + const id = `prt_shell_default_${expanded}` + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage([shell(id, "completed", lines(3))])], + settings: { shellToolPartsExpanded: expanded }, + }) + const trigger = page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`) + await expect(trigger).toHaveAttribute("aria-expanded", String(expanded)) + await trigger.click() + await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded)) + + await timeline.send(partUpdated(shell(id, "completed", lines(6))), 180) + await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")), 180) + await timeline.send(status("busy"), 100) + await timeline.send(status("idle"), 250) + await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded)) + }) +} + +test("shows and expands a running shell command without shimmering it", async ({ page }) => { + const id = "prt_shell_running_command" + const command = "sleep 10 && echo done" + await setupTimeline(page, { + messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })], + settings: { shellToolPartsExpanded: false }, + }) + + const tool = page.locator(`[data-timeline-part-id="${id}"]`) + await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true") + await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command) + await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0) + await tool.locator('[data-slot="collapsible-trigger"]').click() + await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true") + await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running") +}) + +test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => { + const reasoningID = "prt_reasoning_hidden" + const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false }) + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistant], + settings: { showReasoningSummaries: false }, + cpuRate: 4, + }) + await timeline.send(status("busy"), 150) + + await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() + await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible() + await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0) + await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160) + await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() + await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180) + await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100) + await timeline.send(status("idle"), 300) + await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0) +}) + +test("moves busy through retry and recovery to final idle content", async ({ page }) => { + const assistant = assistantMessage([], { completed: false }) + const timeline = await setupTimeline(page, { + messages: [ + userMessage(undefined, { + summary: { + diffs: [ + { + file: "src/retry.ts", + additions: 1, + deletions: 1, + patch: "@@ -1 +1 @@\n-export const retry = false\n+export const retry = true", + }, + ], + }, + }), + assistant, + ], + }) + await timeline.send(status("busy"), 140) + await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() + await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0) + await timeline.send(status("retry"), 180) + await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible() + await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await timeline.send(status("busy", 2), 180) + await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() + await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140) + await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100) + await timeline.send(status("idle"), 350) + await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0) + await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible() +}) + +function lines(count: number) { + return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n") +} diff --git a/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts b/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts new file mode 100644 index 000000000000..d45ec0bd627b --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from "@playwright/test" +import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture" + +for (const profile of [ + { locale: "de", label: "Erkundung abgeschlossen" }, + { locale: "ar", label: "تم الاستكشاف" }, +] as const) { + test(`projects translated context status in ${profile.locale}`, async ({ page }) => { + const ids = [`prt_locale_${profile.locale}_01_read`, `prt_locale_${profile.locale}_02_glob`] + await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }), + toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }), + ]), + ], + locale: profile.locale, + }) + + const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`) + await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", profile.label) + await expect(page.locator("html")).toHaveAttribute("lang", profile.locale) + }) +} diff --git a/packages/app/e2e/regression/session-timeline-projection.spec.ts b/packages/app/e2e/regression/session-timeline-projection.spec.ts new file mode 100644 index 000000000000..b1aabcc32ca9 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-projection.spec.ts @@ -0,0 +1,287 @@ +import { expect, test } from "@playwright/test" +import { + assistantMessage, + setupTimeline, + status, + toolPart, + userMessage, + userText, + type PartSeed, +} from "../performance/timeline-stability/fixture" + +test.describe("session timeline projection", () => { + test("renders every admitted tool family and hides timeline-only exclusions", async ({ page }) => { + const parts = [ + toolPart("prt_01_read", "read", "completed", { filePath: "src/a.ts" }), + toolPart("prt_02_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }), + toolPart("prt_03_grep", "grep", "completed", { path: ".", pattern: "value" }), + toolPart("prt_04_list", "list", "completed", { path: "src" }), + toolPart("prt_webfetch", "webfetch", "completed", { url: "https://example.com" }), + toolPart( + "prt_websearch", + "websearch", + "completed", + { query: "timeline stability" }, + { output: "https://example.com/result" }, + ), + toolPart("prt_task", "task", "completed", { description: "Inspect timeline", subagent_type: "explore" }), + toolPart( + "prt_bash", + "bash", + "completed", + { command: "printf stable" }, + { output: "stable", title: "printf stable" }, + ), + editPart("prt_edit"), + toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }), + patchPart("prt_patch"), + toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }), + toolPart( + "prt_question", + "question", + "completed", + { questions: [{ question: "Keep stable?", header: "Stability", options: [] }] }, + { metadata: { answers: [["Yes"]] } }, + ), + toolPart("prt_skill", "skill", "completed", { name: "stability" }), + toolPart("prt_custom", "custom_mcp_tool", "completed", { target: "timeline", count: 2 }), + ] + await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) + + await expect( + page.locator('[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list"]'), + ).toBeVisible() + for (const id of [ + "prt_webfetch", + "prt_websearch", + "prt_task", + "prt_bash", + "prt_edit", + "prt_write", + "prt_patch", + "prt_question", + "prt_skill", + "prt_custom", + ]) { + await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible() + } + await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0) + }) + + test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => { + const firstUser = userMessage( + [ + userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", { + id: "prt_comment", + synthetic: true, + metadata: { + opencodeComment: { + path: "src/a.ts", + selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 }, + comment: "Keep this stable", + }, + }, + }), + userText("Continue after the comment", { id: "prt_visible_user" }), + ], + { summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } }, + ) + const aborted = assistantMessage( + [ + { id: "prt_before_abort", type: "text", text: "Before interruption" }, + { id: "prt_compaction", type: "compaction", auto: true }, + ], + { + id: "msg_1001_assistant_aborted", + error: { name: "MessageAbortedError", data: { message: "Stopped" } }, + }, + ) + const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], { + id: "msg_1002_assistant_failed", + error: { + name: "APIError", + data: { + message: JSON.stringify({ error: { type: "provider_error", message: "Visible provider failure" } }), + isRetryable: false, + }, + }, + created: 1700000003000, + }) + const nextUser = userMessage([userText("Second turn", { id: "prt_second_user" })], { + id: "msg_2000_second_user", + created: 1700000005000, + }) + const nextAssistant = assistantMessage([{ id: "prt_second_text", type: "text", text: "Second response" }], { + id: "msg_2001_second_assistant", + parentID: "msg_2000_second_user", + created: 1700000006000, + }) + const timeline = await setupTimeline(page, { messages: [firstUser, aborted, failed, nextUser, nextAssistant] }) + await timeline.send(status("idle"), 100) + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + await scroller.evaluate((element) => (element.scrollTop = 0)) + + await expect(page.locator('[data-timeline-row="TurnDivider"]')).toHaveCount(1) + await expect(page.getByText("Session compacted", { exact: true })).toBeVisible() + await expect(page.getByText("Visible provider failure")).toBeVisible() + await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight)) + await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible() + }) + + test("renders comment strips and historical diff summary overflow", async ({ page }) => { + const user = userMessage( + [ + userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", { + id: "prt_comment_only", + synthetic: true, + metadata: { + opencodeComment: { + path: "src/a.ts", + selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 }, + comment: "Keep this stable", + }, + }, + }), + userText("Continue after the comment", { id: "prt_comment_visible" }), + ], + { summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } }, + ) + const nextUser = userMessage(undefined, { id: "msg_2000_diff_next_user", created: 1700000010000 }) + const nextAssistant = assistantMessage([], { + id: "msg_2001_diff_next_assistant", + parentID: "msg_2000_diff_next_user", + created: 1700000011000, + }) + await setupTimeline(page, { + messages: [user, assistantMessage(), nextUser, nextAssistant], + settings: { newLayoutDesigns: false }, + }) + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + await scroller.evaluate((element) => (element.scrollTop = 0)) + + await expect(page.locator('[data-timeline-row="CommentStrip"]')).toBeVisible() + await expect(page.getByText("Keep this stable", { exact: true })).toBeVisible() + await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible() + await expect(page.getByText(/show all/i)).toBeVisible() + }) + + test("renders interruption independently when the turn is not compacted", async ({ page }) => { + const user = userMessage() + const before = assistantMessage([{ id: "prt_before", type: "text", text: "Before" }], { + id: "msg_1001_before", + error: { name: "MessageAbortedError", data: { message: "Stopped" } }, + }) + const after = assistantMessage([{ id: "prt_after", type: "text", text: "After" }], { + id: "msg_1002_after", + created: 1700000003000, + }) + await setupTimeline(page, { messages: [user, before, after] }) + + await expect(page.getByText("Interrupted", { exact: true })).toBeVisible() + const rows = await page + .locator('[data-timeline-row="AssistantPart"], [data-timeline-row="TurnDivider"]') + .evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-row"))) + expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"]) + }) + + test("renders user image, file attachment, file reference, and agent reference", async ({ page }) => { + const text = "Use @explore with @src/a.ts and inspect the attachments" + const parts: PartSeed<"user">[] = [ + userText(text, { id: "prt_user_rich" }), + { + id: "prt_user_image", + type: "file", + mime: "image/png", + filename: "pixel.png", + url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + }, + { + id: "prt_user_attachment", + type: "file", + mime: "application/json", + filename: "tsconfig.json", + url: "data:application/json;base64,e30=", + }, + { + id: "prt_user_reference", + type: "file", + mime: "text/plain", + filename: "a.ts", + url: "src/a.ts", + source: { type: "file", path: "src/a.ts", text: { value: "@src/a.ts", start: 18, end: 27 } }, + }, + { + id: "prt_user_agent", + type: "agent", + name: "explore", + source: { value: "@explore", start: 4, end: 12 }, + }, + ] + await setupTimeline(page, { messages: [userMessage(parts), assistantMessage()] }) + + await expect(page.getByAltText("pixel.png")).toBeVisible() + await expect(page.getByText("tsconfig.json")).toBeVisible() + await expect(page.getByText("@src/a.ts", { exact: true })).toBeVisible() + await expect(page.getByText("@explore", { exact: true })).toBeVisible() + }) +}) + +function editPart(id: string) { + return toolPart( + id, + "edit", + "completed", + { filePath: "src/a.ts" }, + { + metadata: { + filediff: { + file: "src/a.ts", + additions: 1, + deletions: 1, + before: "export const value = 1\n", + after: "export const value = 2\n", + }, + }, + }, + ) +} + +function patchPart(id: string) { + return toolPart( + id, + "apply_patch", + "completed", + { files: ["src/a.ts", "src/b.ts"] }, + { + metadata: { + files: [ + patchFile("src/a.ts", "update"), + patchFile("src/b.ts", "add"), + patchFile("src/old.ts", "delete"), + { ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" }, + ], + }, + }, + ) +} + +function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") { + return { + filePath, + relativePath: filePath, + type, + additions: type === "delete" ? 0 : 1, + deletions: type === "add" ? 0 : 1, + before: type === "add" ? undefined : "export const before = true\n", + after: type === "delete" ? undefined : "export const after = true\n", + } +} + +function summaryDiff(index: number) { + return { + file: `src/diff-${index}.ts`, + additions: 1, + deletions: 1, + patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`, + } +} diff --git a/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts new file mode 100644 index 000000000000..7c0864e5845b --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts @@ -0,0 +1,93 @@ +import { expect, test } from "@playwright/test" +import { + assistantMessage, + reasoningPart, + setupTimeline, + status, + textPart, + toolPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +const profiles = [ + { name: "summaries off no reasoning", summaries: false, reasoning: "", other: false, thinking: true, body: false }, + { + name: "summaries off reasoning heading", + summaries: false, + reasoning: "## Inspecting stability", + other: false, + thinking: true, + body: false, + }, + { + name: "summaries off with visible tool", + summaries: false, + reasoning: "## Inspecting stability", + other: true, + thinking: true, + body: false, + }, + { name: "summaries on no content", summaries: true, reasoning: "", other: false, thinking: true, body: false }, + { + name: "summaries on blank reasoning", + summaries: true, + reasoning: " ", + other: false, + thinking: true, + body: false, + }, + { + name: "summaries on visible reasoning", + summaries: true, + reasoning: "## Inspecting stability", + other: false, + thinking: false, + body: true, + }, + { + name: "summaries on visible tool no reasoning", + summaries: true, + reasoning: "", + other: true, + thinking: false, + body: false, + }, +] as const + +for (const profile of profiles) { + test(`projects busy reasoning profile ${profile.name}`, async ({ page }) => { + const reasoningID = `prt_reasoning_matrix_${profiles.indexOf(profile)}` + const parts = [ + ...(profile.reasoning ? [reasoningPart(reasoningID, profile.reasoning)] : []), + ...(profile.other + ? [toolPart(`prt_reasoning_tool_${profiles.indexOf(profile)}`, "skill", "running", { name: "inspect" })] + : []), + ] + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage(parts, { completed: false })], + settings: { showReasoningSummaries: profile.summaries }, + }) + await timeline.send(status("busy"), 150) + + await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0) + await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(profile.body ? 1 : 0) + if (!profile.summaries && profile.reasoning.trim()) { + await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible() + } + }) +} + +test("does not infer reasoning visibility from provider identity", async ({ page }) => { + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([textPart("prt_provider_text", "No reasoning payload")], { completed: false }), + ], + settings: { showReasoningSummaries: true }, + }) + await timeline.send(status("busy"), 150) + + await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0) + await expect(page.locator('[data-timeline-part-id="prt_provider_text"]')).toBeVisible() +}) diff --git a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts new file mode 100644 index 000000000000..ad35eef601cb --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from "@playwright/test" +import { + assistantMessage, + completedAssistantInfo, + messageUpdated, + partUpdated, + setupTimeline, + shell, + status, + textPart, + toolPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +test("groups singleton and separated context operations at correct boundaries", async ({ page }) => { + const parts = [ + toolPart("prt_boundary_01_read", "read", "completed", { filePath: "src/a.ts" }), + textPart("prt_boundary_02_text", "Boundary text"), + toolPart("prt_boundary_03_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }), + toolPart("prt_boundary_04_grep", "grep", "completed", { path: ".", pattern: "stable" }), + shell("prt_boundary_05_shell", "completed", "done"), + toolPart("prt_boundary_06_list", "list", "completed", { path: "src" }), + ] + await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) + + await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible() + await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible() + await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible() + await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5) +}) + +test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => { + const textID = "prt_event_order_text" + const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false }) + const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] }) + await timeline.send(status("busy"), 100) + await timeline.send(status("idle"), 100) + await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120) + await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 250) + + await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("Final after early idle") +}) diff --git a/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts b/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts new file mode 100644 index 000000000000..54139cc3711b --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts @@ -0,0 +1,228 @@ +import { expect, test, type Locator, type Page } from "@playwright/test" +import { + assistantMessage, + setupTimeline, + shell, + textPart, + toolPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +for (const deviceScaleFactor of [1.25, 1.5]) { + test(`keeps the shell outline inside a fractionally short virtual row at ${deviceScaleFactor}x`, async ({ page }) => { + const shellID = "prt_shell_outline" + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])], + settings: { newLayoutDesigns: true, shellToolPartsExpanded: true }, + reducedMotion: true, + deviceScaleFactor, + }) + const part = page.locator(`[data-timeline-part-id="${shellID}"]`) + const output = part.locator('[data-component="bash-output"]') + const row = page.locator("[data-timeline-key]", { has: part }) + await expect(output).toBeVisible() + await timeline.settle() + + const geometry = await row.evaluate((element) => { + const output = element.querySelector('[data-component="bash-output"]') + if (!output) throw new Error("Shell output is unavailable") + const rowRect = element.getBoundingClientRect() + const outputRect = output.getBoundingClientRect() + // Match a rounded-down measurement at a fractional device-pixel phase. + element.style.height = `${outputRect.bottom - rowRect.top - 0.49}px` + element.style.transform = "translateY(0.25px)" + output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)") + output.style.setProperty("background", "rgb(0, 0, 0)", "important") + const style = getComputedStyle(output) + return { + outputWidth: outputRect.width, + outputHeight: outputRect.height, + borderColor: style.borderTopColor, + boxShadow: style.boxShadow, + clipMargin: getComputedStyle(element).overflowClipMargin, + } + }) + await timeline.settle() + + const clipped = await row.evaluate((element) => { + const output = element.querySelector('[data-component="bash-output"]')! + return output.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom + }) + expect(clipped).toBeCloseTo(0.49, 1) + + expect(await page.evaluate(() => devicePixelRatio)).toBe(deviceScaleFactor) + const edges = await captureCardEdges(page, output) + + expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2) + expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2) + expect(geometry.borderColor).toBe("rgb(255, 0, 255)") + expect(geometry.boxShadow).toBe("none") + expect(geometry.clipMargin).toBe("0.5px") + expect(edges.magenta.top).toBeGreaterThan(0.75) + expect(edges.magenta.bottom).toBeGreaterThan(0.75) + expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2) + }) +} + +test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => { + const patchID = "prt_patch_outline" + const file = { + filePath: "src/outline.ts", + relativePath: "src/outline.ts", + type: "update", + additions: 1, + deletions: 1, + before: "const outline = false\n", + after: "const outline = true\n", + } + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }), + ]), + ], + settings: { editToolPartsExpanded: true, newLayoutDesigns: true }, + reducedMotion: true, + }) + const part = page.locator(`[data-timeline-part-id="${patchID}"]`) + const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]') + const row = page.locator("[data-timeline-key]", { has: part }) + await expect(card).toBeVisible() + await timeline.settle() + + const geometry = await row.evaluate((element) => { + const card = element.querySelector('[data-component="accordion"][data-scope="apply-patch"]') + if (!card) throw new Error("Patch card is unavailable") + const rowRect = element.getBoundingClientRect() + const cardRect = card.getBoundingClientRect() + element.style.height = `${cardRect.bottom - rowRect.top - 0.49}px` + const clipMargin = getComputedStyle(element).overflowClipMargin + const bottom = element.getBoundingClientRect().bottom + return { + overflow: card.getBoundingClientRect().bottom - bottom, + paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin), + clipMargin, + cardWidth: cardRect.width, + cardHeight: cardRect.height, + } + }) + await timeline.settle() + + expect(geometry.overflow).toBeCloseTo(0.49, 1) + expect(geometry.paintOverflow).toBeLessThanOrEqual(0) + const edges = await captureCardEdges(page, card) + expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2) + expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2) + expect(edges.luminance.top).toBeLessThan(245) + expect(edges.luminance.bottom).toBeLessThan(245) + expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10) + expect(geometry.clipMargin).toBe("0.5px") +}) + +test("allows paint rounding for every framed row but not fixed turn gaps", async ({ page }) => { + const secondUserID = "msg_outline_second_user" + await setupTimeline(page, { + messages: [ + userMessage(undefined, { + summary: { + diffs: [ + { + file: "src/summary.ts", + additions: 1, + deletions: 1, + patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2", + }, + ], + }, + }), + assistantMessage([textPart("prt_outline_text", "Assistant text")]), + userMessage(undefined, { id: secondUserID, created: 1700000010000 }), + assistantMessage([], { + id: "msg_outline_second_assistant", + parentID: secondUserID, + created: 1700000011000, + }), + ], + }) + await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible() + await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible() + + const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) => + elements.map((element) => ({ + tag: element.querySelector("[data-timeline-row]")?.dataset.timelineRow, + clipMargin: getComputedStyle(element).overflowClipMargin, + })), + ) + expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true) + expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }]) +}) + +async function captureCardEdges(page: Page, card: Locator) { + const box = await card.boundingBox() + if (!box) throw new Error("Tool card bounds are unavailable") + const viewport = page.viewportSize() + if (!viewport) throw new Error("Viewport bounds are unavailable") + const screenshot = await page.screenshot() + return page.evaluate( + async ({ source, box, viewport }) => { + const image = new Image() + image.src = source + await image.decode() + const canvas = document.createElement("canvas") + canvas.width = image.naturalWidth + canvas.height = image.naturalHeight + const context = canvas.getContext("2d") + if (!context) throw new Error("2D canvas is unavailable") + context.drawImage(image, 0, 0) + const scale = { + x: image.naturalWidth / viewport.width, + y: image.naturalHeight / viewport.height, + } + const rows = (candidates: number[]) => { + const left = Math.floor((box.x + 8) * scale.x) + const width = Math.floor((box.width - 16) * scale.x) + return candidates.map((row) => { + const pixels = context.getImageData(left, row, width, 1).data + const indexes = Array.from({ length: width }, (_, index) => index * 4) + return { + luminance: + indexes + .map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3) + .reduce((sum, value) => sum + value, 0) / width, + magenta: + indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200) + .length / width, + } + }) + } + const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data + const columns = new Uint32Array(image.naturalWidth) + for (let index = 0; index < pixels.length; index += 4) { + if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue + columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1 + } + const top = box.y * scale.y + const bottom = (box.y + box.height) * scale.y + const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)]) + const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1]) + return { + box, + luminance: { + top: Math.min(...topRows.map((row) => row.luminance)), + bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance, + }, + magenta: { + top: Math.max(...topRows.map((row) => row.magenta)), + bottom: Math.max(...bottomRows.map((row) => row.magenta)), + vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length, + }, + } + }, + { + source: `data:image/png;base64,${screenshot.toString("base64")}`, + viewport, + box, + }, + ) +} diff --git a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts new file mode 100644 index 000000000000..99f1acf270b4 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts @@ -0,0 +1,99 @@ +import { expect, test } from "@playwright/test" +import { + assistantMessage, + partUpdated, + setupTimeline, + toolPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +test("renders every tool error outcome without leaking hidden tools", async ({ page }) => { + const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"] + const parts = ordinary.map((tool, index) => + toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }), + ) + parts.push( + toolPart("prt_question_dismissed", "question", "error", questionInput(), { + error: "The user dismissed this question", + }), + toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }), + toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }), + ) + await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) + + await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1) + await expect(page.getByText(/dismissed/i)).toBeVisible() + await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0) + for (let index = 0; index < ordinary.length; index++) { + await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible() + } +}) + +test("transitions shell and question through running error outcomes", async ({ page }) => { + const shellID = "prt_transition_error_shell" + const questionID = "prt_transition_error_question" + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage( + [ + toolPart(shellID, "bash", "pending", { command: "exit 1" }), + toolPart(questionID, "question", "pending", questionInput()), + ], + { completed: false }, + ), + ], + }) + await timeline.waitForPart(shellID) + await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0) + await timeline.send(partUpdated(toolPart(shellID, "bash", "running", { command: "exit 1" })), 120) + await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180) + await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0) + await timeline.send( + partUpdated(toolPart(shellID, "bash", "error", { command: "exit 1" }, { error: "Command exited 1" })), + 180, + ) + await timeline.send( + partUpdated( + toolPart(questionID, "question", "error", questionInput(), { error: "The user dismissed this question" }), + ), + 250, + ) + + await expect(page.locator(`[data-timeline-part-id="${shellID}"] [data-kind="tool-error-card"]`)).toBeVisible() + await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i) +}) + +test("labels all web search provider variants", async ({ page }) => { + const parts = [ + toolPart( + "prt_search_parallel", + "websearch", + "completed", + { query: "parallel" }, + { metadata: { provider: "parallel" } }, + ), + toolPart("prt_search_exa", "websearch", "completed", { query: "exa" }, { metadata: { provider: "exa" } }), + toolPart("prt_search_generic", "websearch", "completed", { query: "generic" }), + ] + await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) + + await expect(page.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible() + await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible() + await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible() +}) + +function questionInput() { + return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] } +} + +function errorInput(tool: string) { + if (tool === "bash") return { command: "exit 1" } + if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" } + if (tool === "apply_patch") return { files: ["src/error.ts"] } + if (tool === "webfetch") return { url: "https://example.com" } + if (tool === "websearch") return { query: "failure" } + if (tool === "task") return { description: "Fail task", subagent_type: "explore" } + if (tool === "skill") return { name: "failure" } + return { target: "failure" } +} diff --git a/packages/app/e2e/regression/session-timeline-tool-state.spec.ts b/packages/app/e2e/regression/session-timeline-tool-state.spec.ts new file mode 100644 index 000000000000..63646f4454c6 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-tool-state.spec.ts @@ -0,0 +1,77 @@ +import { expect, test } from "@playwright/test" +import { + assistantMessage, + partUpdated, + setupTimeline, + toolPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +test("updates expanded web search links without resetting expansion", async ({ page }) => { + const searchID = "prt_websearch_mutation" + const input = { query: "timeline stability" } + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([toolPart(searchID, "websearch", "completed", input, { output: "https://example.com/one" })]), + ], + }) + const wrapper = page.locator(`[data-timeline-part-id="${searchID}"]`) + const trigger = wrapper.locator('[data-slot="collapsible-trigger"]') + await trigger.click() + await expect(trigger).toHaveAttribute("aria-expanded", "true") + await timeline.send( + partUpdated( + toolPart(searchID, "websearch", "completed", input, { + output: "https://example.com/one\nhttps://example.com/two", + }), + ), + 300, + ) + await expect(trigger).toHaveAttribute("aria-expanded", "true") + await expect(wrapper.locator('a[href="https://example.com/two"]')).toBeVisible() +}) + +test("preserves an expanded tool error card across duplicate delivery", async ({ page }) => { + const toolID = "prt_duplicate_error" + const failed = toolPart(toolID, "bash", "error", { command: "exit 1" }, { error: "Command failed visibly" }) + const timeline = await setupTimeline(page, { messages: [userMessage(), assistantMessage([failed])] }) + const wrapper = page.locator(`[data-timeline-part-id="${toolID}"]`) + const trigger = wrapper.locator('[data-slot="collapsible-trigger"]') + await trigger.click() + await expect(trigger).toHaveAttribute("aria-expanded", "true") + await timeline.send(partUpdated(failed), 150) + await timeline.send(partUpdated(failed), 250) + await expect(trigger).toHaveAttribute("aria-expanded", "true") + await expect(wrapper).toContainText("Command failed visibly") +}) + +test("renders multiple question answers and preserves open state on answer updates", async ({ page }) => { + const questionID = "prt_multi_question" + const input = { + questions: [ + { header: "First", question: "First choice?", options: [] }, + { header: "Second", question: "Second choice?", options: [], multiple: true }, + ], + } + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + toolPart(questionID, "question", "completed", input, { metadata: { answers: [["A"], ["B", "C"]] } }), + ]), + ], + }) + const wrapper = page.locator(`[data-timeline-part-id="${questionID}"]`) + const trigger = wrapper.locator('[data-slot="collapsible-trigger"]') + await expect(trigger).toHaveAttribute("aria-expanded", "true") + await timeline.send( + partUpdated( + toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Updated"], ["B", "C"]] } }), + ), + 300, + ) + await expect(trigger).toHaveAttribute("aria-expanded", "true") + await expect(wrapper).toContainText("Updated") + await expect(wrapper).toContainText("B, C") +}) diff --git a/packages/app/e2e/regression/session-timeline-transport.spec.ts b/packages/app/e2e/regression/session-timeline-transport.spec.ts new file mode 100644 index 000000000000..359804997876 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-transport.spec.ts @@ -0,0 +1,154 @@ +import { expect, test, type Page } from "@playwright/test" +import { + assistantMessage, + partUpdated, + setupTimeline, + textPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +test("keeps one connection open while delivering multiple events", async ({ page }) => { + const timeline = await setupTimeline(page) + + const first = await timeline.transport.send(partUpdated(textPart("prt_transport_first", "first event"))) + const second = await timeline.transport.send(partUpdated(textPart("prt_transport_second", "second event"))) + + await timeline.waitForPart("prt_transport_first") + await timeline.waitForPart("prt_transport_second") + expect(first.connectionID).toBe(second.connectionID) + await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) + expect(await timeline.transport.acknowledgements()).toHaveLength(2) +}) + +test("delivers a burst from one stream chunk", async ({ page }) => { + const timeline = await setupTimeline(page) + const acknowledgements = await timeline.transport.burst([ + partUpdated(textPart("prt_transport_burst_a", "burst a")), + partUpdated(textPart("prt_transport_burst_b", "burst b")), + ]) + + await timeline.waitForPart("prt_transport_burst_a") + await timeline.waitForPart("prt_transport_burst_b") + expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1]) + expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(2) +}) + +test("parses split JSON and a split multibyte code point", async ({ page }) => { + const timeline = await setupTimeline(page) + const payload = partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603")) + const encoded = new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`) + const snowman = new TextEncoder().encode("\u2603")[0]! + const multibyte = encoded.indexOf(snowman) + + const acknowledgement = await timeline.transport.split(payload, [9, multibyte + 1, multibyte + 2]) + + await timeline.waitForPart("prt_transport_split") + await expect(page.locator('[data-timeline-part-id="prt_transport_split"]')).toContainText( + "split snowman \u2603\u2603\u2603", + ) + expect(acknowledgement.chunkCount).toBe(4) +}) + +test("delivers server heartbeat without mutating the timeline", async ({ page }) => { + const sentinelID = "prt_transport_heartbeat_sentinel" + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])], + }) + await timeline.waitForPart("prt_transport_steady") + const before = await stableTimelineRows(page) + + await timeline.transport.writeRaw(": heartbeat\n\n") + await timeline.transport.send(partUpdated(textPart(sentinelID, "heartbeat processed"))) + await timeline.waitForPart(sentinelID) + + await expect + .poll(async () => { + const rows = await timelineRows(page) + return rows.filter((row) => before.some((item) => item.key === row.key)) + }) + .toEqual(before) + await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) +}) + +test("reconnects after a clean close", async ({ page }) => { + const timeline = await setupTimeline(page) + const first = await timeline.transport.waitForConnection() + + await timeline.transport.close() + const second = await timeline.transport.waitForConnection({ after: first.id }) + await timeline.transport.send(partUpdated(textPart("prt_transport_close", "after close"))) + + await timeline.waitForPart("prt_transport_close") + expect(second.id).toBeGreaterThan(first.id) + expect((await timeline.transport.connections())[0]?.endedBy).toBe("close") +}) + +test("reconnects after a stream error", async ({ page }) => { + const timeline = await setupTimeline(page) + const first = await timeline.transport.waitForConnection() + + await timeline.transport.error("contract failure") + const second = await timeline.transport.waitForConnection({ after: first.id }) + await timeline.transport.send(partUpdated(textPart("prt_transport_error", "after error"))) + + await timeline.waitForPart("prt_transport_error") + await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2) + expect(second.id).toBeGreaterThan(first.id) + expect((await timeline.transport.connections())[0]?.endedBy).toBe("error") +}) + +test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => { + const timeline = await setupTimeline(page, { protocol: "v2" }) + const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), { + id: "timeline-event-7", + }) + await timeline.waitForPart("prt_transport_id") + + await timeline.transport.error("retry with event id") + const connection = await timeline.transport.waitForConnection({ after: first.connectionID }) + + expect(first.eventID).toBe("timeline-event-7") + expect(connection.headers["last-event-id"]).toBeUndefined() +}) + +test("passes through non-event fetches", async ({ page }) => { + const timeline = await setupTimeline(page) + + const health = await page.evaluate(async () => { + const response = await fetch("/global/health") + return response.json() + }) + + expect(health).toEqual({ healthy: true }) + await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) +}) + +async function stableTimelineRows(page: Page) { + let previous: Awaited> | undefined + let stable = 0 + await expect + .poll( + async () => { + const next = await timelineRows(page) + stable = JSON.stringify(next) === JSON.stringify(previous) ? stable + 1 : 0 + previous = next + return stable + }, + { intervals: [50, 50, 100] }, + ) + .toBeGreaterThanOrEqual(2) + return previous! +} + +function timelineRows(page: Page) { + return page.locator("[data-timeline-key]").evaluateAll((elements) => + elements.map((element) => ({ + key: element.getAttribute("data-timeline-key"), + row: element.querySelector("[data-timeline-row]")?.getAttribute("data-timeline-row"), + parts: Array.from(element.querySelectorAll("[data-timeline-part-id]"), (part) => + part.getAttribute("data-timeline-part-id"), + ), + text: element.textContent, + })), + ) +} diff --git a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts new file mode 100644 index 000000000000..55e71212753c --- /dev/null +++ b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts @@ -0,0 +1,190 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/TodoDockNavigation" +const projectID = "proj_todo_dock_navigation" +const sourceID = "ses_todo_dock_source" +const otherID = "ses_todo_dock_other" +const sourceTitle = "Todo dock animation" +const otherTitle = "Separate session" + +const activeTodos = [ + { id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" }, + { id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" }, + { id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" }, +] + +type EventPayload = { + directory: string + payload: Record +} + +test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" }) + +test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => { + test.setTimeout(90_000) + const events: EventPayload[] = [] + const todos: Record = { [sourceID]: [], [otherID]: [] } + const sessionStatus: Record = {} + + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "todo-dock-navigation", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { + "claude-opus-4-6": { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + limit: { context: 200_000 }, + }, + }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "claude-opus-4-6" }, + }, + sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)], + sessionStatus: { [sourceID]: { type: "busy" } }, + pageMessages: () => ({ items: [] }), + events: () => events.splice(0, 1), + eventRetry: 16, + sessionStatus: () => sessionStatus, + todos: (sessionID) => todos[sessionID] ?? [], + }) + await configurePage(page) + + await page.goto(sessionHref(sourceID)) + await expectSessionTitle(page, sourceTitle) + const dock = page.locator('[data-component="session-todo-dock"]') + await expect(dock).toHaveCount(0) + + sessionStatus[sourceID] = { type: "busy" } + events.push(statusEvent(sourceID, "busy")) + await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() + + await page.waitForTimeout(700) + const opening = sampleDock(page, 1_000) + todos[sourceID] = activeTodos + events.push(todoEvent(sourceID, activeTodos)) + await expect(dock).toBeVisible() + await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1) + expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true) + + await switchSession(page, otherID, otherTitle) + await expect(dock).toHaveCount(0) + + const returningOpen = sampleDock(page, 700) + await switchSession(page, sourceID, sourceTitle) + const openSamples = (await returningOpen).filter((sample) => sample.present) + expect(openSamples.length).toBeGreaterThan(0) + expect(openSamples[0]!.opacity).toBeGreaterThan(0.98) + expect(openSamples[0]!.height).toBeGreaterThan(70) + await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1) + + const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" })) + const closing = sampleDock(page, 1_000) + todos[sourceID] = completedTodos + events.push(todoEvent(sourceID, completedTodos)) + await expect(dock).toHaveCount(0) + expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true) + todos[sourceID] = [] + events.push(todoEvent(sourceID, [])) + + await switchSession(page, otherID, otherTitle) + const returningEmpty = sampleDock(page, 700) + await switchSession(page, sourceID, sourceTitle) + await expect(dock).toHaveCount(0) + expect((await returningEmpty).every((sample) => !sample.present)).toBe(true) +}) + +function session(id: string, title: string, created: number) { + return { + id, + slug: id, + projectID, + directory, + title, + version: "dev", + time: { created, updated: created }, + } +} + +function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload { + return { + directory, + payload: { type: "session.status", properties: { sessionID, status: { type } } }, + } +} + +function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload { + return { + directory, + payload: { type: "todo.updated", properties: { sessionID, todos: next } }, + } +} + +async function configurePage(page: Page) { + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + await page.addInitScript( + ({ directory, dirBase64, server, sessionIDs }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))), + ) + }, + { directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] }, + ) +} + +function sessionHref(sessionID: string) { + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + return `/server/${base64Encode(server)}/session/${sessionID}` +} + +async function switchSession(page: Page, sessionID: string, title: string) { + const href = sessionHref(sessionID) + const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first() + await expect(tab).toBeVisible() + await tab.click() + await expectSessionTitle(page, title) +} + +function sampleDock(page: Page, duration: number) { + return page.evaluate(async (duration) => { + const samples: { present: boolean; height: number; opacity: number }[] = [] + const start = performance.now() + while (performance.now() - start < duration) { + const dock = document.querySelector('[data-component="session-todo-dock"]') + const clip = dock?.parentElement?.parentElement + const label = dock?.querySelector('[data-action="session-todo-toggle"] span[aria-label]') + samples.push({ + present: !!dock, + height: clip?.getBoundingClientRect().height ?? 0, + opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0, + }) + await new Promise(requestAnimationFrame) + } + return samples + }, duration) +} diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts new file mode 100644 index 000000000000..019cc156eca1 --- /dev/null +++ b/packages/app/e2e/regression/subagent-child-navigation.spec.ts @@ -0,0 +1,203 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { currentSession, mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/SubagentNavigation" +const projectID = "proj_subagent_navigation" +const parentID = "ses_subagent_parent" +const childID = "ses_subagent_child" +const parentTitle = "Parent session" +const childTitle = "Subagent child session" +// Child session pages derive their heading from the task part that spawned them. +const taskDescription = "Inspect child navigation" + +type EventPayload = { directory: string; payload: Record } + +test.use({ viewport: { width: 1440, height: 900 } }) + +test("navigates to a subagent child session missing from the session list", async ({ page }) => { + await setup(page) + await openChildFromParent(page) + + await expectSessionTitle(page, taskDescription) + await expect(page.getByRole("heading", { name: parentTitle })).toHaveCount(0) + + const titlebarRight = page.locator("#opencode-titlebar-right") + await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1) +}) + +test("shows the not found fallback when the viewed session is deleted", async ({ page }) => { + const events: EventPayload[] = [] + await setup(page, () => events.splice(0, 1)) + await openChildFromParent(page) + await expectSessionTitle(page, taskDescription) + + events.push({ + directory, + payload: { type: "session.deleted", properties: { info: childSession() } }, + }) + + await expect(page.getByText("This session cannot be found")).toBeVisible() + await expect(page.getByRole("button", { name: "Close Tab" })).toBeVisible() + await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0) +}) + +async function setup(page: Page, events?: () => EventPayload[]) { + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "subagent-navigation", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { + "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } }, + }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "claude-opus-4-6" }, + }, + sessions: [session(parentID, parentTitle, 1700000000000), childSession()], + pageMessages: (sessionID) => ({ items: sessionID === parentID ? parentMessages() : [] }), + events, + eventRetry: events ? 16 : undefined, + }) + // The child session resolves by ID but is absent from the session list, + // matching a subagent session that has not been loaded into the list cache yet. + await page.route( + (url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"), + (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify({ + data: [currentSession(session(parentID, parentTitle, 1700000000000))], + cursor: {}, + }), + }), + ) + await configurePage(page) +} + +async function openChildFromParent(page: Page) { + await page.goto(sessionHref(parentID)) + await expectSessionTitle(page, parentTitle) + + const card = page.locator(`a[href="${sessionHref(childID)}"]`) + await expect(card).toBeVisible() + await card.click() + + await expect(page).toHaveURL(new RegExp(`/server/.+/session/${childID}$`), { timeout: 15_000 }) +} + +function session(id: string, title: string, created: number, extra?: Record) { + return { + id, + slug: id, + projectID, + directory, + title, + version: "dev", + time: { created, updated: created }, + ...extra, + } +} + +function childSession() { + return session(childID, childTitle, 1700000001000, { parentID }) +} + +function parentMessages() { + const userID = "msg_user_0001" + const assistantID = "msg_assistant_0001" + return [ + { + info: { + id: userID, + sessionID: parentID, + role: "user", + time: { created: 1700000000000 }, + agent: "build", + model: { providerID: "opencode", modelID: "claude-opus-4-6" }, + }, + parts: [ + { + id: "prt_user_text_0001", + sessionID: parentID, + messageID: userID, + type: "text", + text: "Delegate work to a subagent", + }, + ], + }, + { + info: { + id: assistantID, + sessionID: parentID, + role: "assistant", + time: { created: 1700000001000, completed: 1700000002000 }, + parentID: userID, + modelID: "claude-opus-4-6", + providerID: "opencode", + mode: "build", + agent: "build", + path: { cwd: directory, root: directory }, + cost: 0.01, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "stop", + }, + parts: [ + { + id: "prt_tool_task_0001", + sessionID: parentID, + messageID: assistantID, + type: "tool", + callID: "call_task_0001", + tool: "task", + state: { + status: "completed", + input: { description: taskDescription, subagent_type: "explore" }, + output: "Subagent finished", + title: taskDescription, + metadata: { sessionId: childID }, + time: { start: 1700000001000, end: 1700000002000 }, + }, + }, + ], + }, + ] +} + +async function configurePage(page: Page) { + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + await page.addInitScript( + ({ directory, server, sessionId }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify([{ type: "session", server, sessionId }])) + }, + { directory, server, sessionId: parentID }, + ) +} + +function sessionHref(sessionID: string) { + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + return `/server/${base64Encode(server)}/session/${sessionID}` +} diff --git a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts new file mode 100644 index 000000000000..b969b590d89b --- /dev/null +++ b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts @@ -0,0 +1,159 @@ +import { expect, test, type Page, type Route } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { currentSession } from "../utils/mock-server" + +const server = "http://127.0.0.1:4096" +const sessionA = session("ses_tab_a", "Tab A session") +const sessionB = session("ses_tab_b", "Tab B session") +const sessionC = session("ses_tab_c", "Tab C session") +const unresolvedSessionID = "ses_tab_unresolved" + +test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => { + await mockServer(page) + await page.addInitScript( + ({ server, sessionA, sessionB }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([ + { type: "session", server, sessionId: sessionA }, + { type: "session", server, sessionId: sessionB }, + ]), + ) + }, + { server, sessionA: sessionA.id, sessionB: sessionB.id }, + ) + + const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}` + const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}` + await page.goto(hrefA) + await expect(page.getByText(sessionA.title).first()).toBeVisible() + + const linkB = page.locator(`a[data-titlebar-tab-link][href="${hrefB}"]`) + await expect(linkB).toBeVisible() + const box = await linkB.boundingBox() + if (!box) throw new Error("tab link has no bounding box") + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + await page.mouse.down() + + // Navigation must happen on mousedown, before the button is released. + await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) + await page.mouse.up() + await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) +}) + +test("keyboard navigation follows the visible tab order", async ({ page }) => { + await mockServer(page) + await page.addInitScript( + ({ server, sessionA, unresolved, sessionC }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([ + { type: "session", server, sessionId: sessionA }, + { type: "session", server, sessionId: unresolved }, + { type: "session", server, sessionId: sessionC }, + ]), + ) + }, + { server, sessionA: sessionA.id, unresolved: unresolvedSessionID, sessionC: sessionC.id }, + ) + + const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}` + const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}` + await page.goto(hrefA) + await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(2) + await expect(page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefC}"])`)).toBeVisible() + + await page.keyboard.press("Control+Alt+ArrowRight") + + await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) +}) + +function session(id: string, title: string) { + return { + id, + slug: id, + projectID: "project-tabs", + directory: "C:/tab-project", + title, + version: "dev", + time: { created: 1, updated: 1 }, + } +} + +async function mockServer(page: Page) { + const sessions = [sessionA, sessionB, sessionC] + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()) + if (url.origin !== server) return route.fallback() + if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname)) + return new Promise(() => {}) + if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") + return sse(route) + if (url.pathname === "/global/health") return json(route, { healthy: true }) + if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) + if (url.pathname === "/api/session/active") return json(route, { data: {} }) + const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`) + if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) + if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`)) + return json(route, { data: [], cursor: {} }) + const byId = sessions.find((item) => url.pathname === `/session/${item.id}`) + if (byId) return json(route, byId) + if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) + if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, []) + if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) + if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) + return json(route, []) + if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) + if (url.pathname === "/provider") + return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) + if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) + if (url.pathname === "/project" || url.pathname === "/project/current") { + const project = { + id: sessionA.projectID, + worktree: sessionA.directory, + vcs: "git", + time: { created: 1, updated: 1 }, + sandboxes: [], + } + return json(route, url.pathname === "/project" ? [project] : project) + } + if (url.pathname === "/path") + return json(route, { + state: sessionA.directory, + config: sessionA.directory, + worktree: sessionA.directory, + directory: sessionA.directory, + home: sessionA.directory, + }) + if (url.pathname === "/api/path") + return json(route, { + state: sessionA.directory, + config: sessionA.directory, + worktree: sessionA.directory, + directory: sessionA.directory, + home: sessionA.directory, + }) + if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (url.pathname === "/api/vcs") + return json(route, { + location: { directory: sessionA.directory }, + data: { branch: "main", defaultBranch: "main" }, + }) + return json(route, {}) + }) +} + +function json(route: Route, body: unknown, status = 200) { + return route.fulfill({ + status, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify(body), + }) +} + +function sse(route: Route) { + return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) +} diff --git a/packages/app/e2e/regression/terminal-composer-focus.spec.ts b/packages/app/e2e/regression/terminal-composer-focus.spec.ts new file mode 100644 index 000000000000..99bf68908508 --- /dev/null +++ b/packages/app/e2e/regression/terminal-composer-focus.spec.ts @@ -0,0 +1,227 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/TerminalComposerFocus" +const projectID = "proj_terminal_composer_focus" +const sessionID = "ses_terminal_composer_focus" +const ptyID = "pty_terminal_composer_focus" +const newPtyID = "pty_terminal_composer_focus_new" + +test.use({ viewport: { width: 1440, height: 900 } }) + +test.beforeEach(async ({ page }) => { + await mockOpenCodeServer(page, { + protocol: "v2", + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "terminal-composer-focus", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [ + { + id: sessionID, + slug: "terminal-composer-focus", + projectID, + directory, + title: "Terminal composer focus", + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + pageMessages: () => ({ items: [] }), + }) + await page.route("**/api/pty*", (route) => { + expect(new URL(route.request().url()).searchParams.get("location[directory]")).toBe(directory) + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }), + }) + }) + await page.route(`**/api/pty/${ptyID}*`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }), + }), + ) + await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), + }), + ) + await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined) + await page.addInitScript(() => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + }) +}) + +test("routes typing to the composer unless the open terminal is focused", async ({ page }) => { + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, "Terminal composer focus") + + const composer = page.locator('[data-component="prompt-input"]') + const terminal = page.locator('[data-component="terminal"]') + await page.keyboard.press("Control+Backquote") + await expect(terminal).toBeVisible() + await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true) + + await page.keyboard.type("x") + await expect(composer).toHaveText("") + + await page.waitForTimeout(300) + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) + await page.keyboard.type("a") + + await expect(composer).toBeFocused() + await expect(composer).toHaveText("a") +}) + +test("keeps composer focus when a cached terminal finishes mounting", async ({ page }) => { + const ghostty = Promise.withResolvers() + const release = Promise.withResolvers() + const created = { count: 0 } + await page.route("**/api/pty*", (route) => { + created.count += 1 + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }), + }) + }) + await page.route(/ghostty-web/, async (route) => { + ghostty.resolve() + await release.promise + await route.continue() + }) + await seedCachedTerminal(page) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" }) + await expectSessionTitle(page, "Terminal composer focus") + + const composer = page.locator('[data-component="prompt-input"]') + const terminal = page.locator('[data-component="terminal"]') + await expect(terminal).toBeVisible() + expect(created.count).toBe(0) + await ghostty.promise + await composer.click() + await expect(composer).toBeFocused() + + release.resolve() + await expect(terminal.locator("textarea")).toHaveCount(1) + await page.waitForTimeout(300) + await expect(composer).toBeFocused() +}) + +test("keeps newer composer focus while an explicit terminal open finishes", async ({ page }) => { + const ghostty = Promise.withResolvers() + const release = Promise.withResolvers() + await page.route(/ghostty-web/, async (route) => { + ghostty.resolve() + await release.promise + await route.continue() + }) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, "Terminal composer focus") + + const composer = page.locator('[data-component="prompt-input"]') + const terminal = page.locator('[data-component="terminal"]') + await page.keyboard.press("Control+Backquote") + await expect(terminal).toBeVisible() + await ghostty.promise + await composer.click() + await expect(composer).toBeFocused() + + release.resolve() + await expect(terminal.locator("textarea")).toHaveCount(1) + await page.waitForTimeout(50) + await expect(composer).toBeFocused() +}) + +test("focuses a terminal created from the new-terminal button", async ({ page }) => { + const created = { count: 0 } + await page.route("**/api/pty*", (route) => { + created.count += 1 + const next = created.count === 1 ? ptyInfo(ptyID, "Terminal 1") : ptyInfo(newPtyID, "Terminal 2") + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ location: ptyLocation(), data: next }), + }) + }) + await page.route(`**/api/pty/${newPtyID}*`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(newPtyID, "Terminal 2") }), + }), + ) + await page.route(`**/api/pty/${newPtyID}/connect-token*`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), + }), + ) + await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, "Terminal composer focus") + + const composer = page.locator('[data-component="prompt-input"]') + const terminal = page.locator('[data-component="terminal"]') + await page.keyboard.press("Control+Backquote") + await expect(terminal.locator("textarea")).toHaveCount(1) + await composer.click() + await expect(composer).toBeFocused() + + await page.getByRole("button", { name: "New terminal" }).click() + await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true") + await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true) +}) + +function seedCachedTerminal(page: Page) { + return page.addInitScript( + ({ terminalKey, ptyID }) => { + localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } })) + localStorage.setItem( + terminalKey, + JSON.stringify({ + active: ptyID, + all: [{ id: ptyID, title: "Terminal 1", titleNumber: 1 }], + }), + ) + }, + { terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID }, + ) +} + +function ptyLocation() { + return { directory, project: { id: projectID, directory } } +} + +function ptyInfo(id: string, title: string) { + return { id, title, command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 } +} diff --git a/packages/app/e2e/regression/terminal-hidden.spec.ts b/packages/app/e2e/regression/terminal-hidden.spec.ts new file mode 100644 index 000000000000..8e08d60ff2ac --- /dev/null +++ b/packages/app/e2e/regression/terminal-hidden.spec.ts @@ -0,0 +1,117 @@ +import { expect, test } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/HiddenTerminalRegression" +const projectID = "proj_hidden_terminal_regression" +const sessionID = "ses_hidden_terminal_regression" +const title = "Hidden terminal regression" + +test("unmounts the terminal panel while it is hidden", async ({ page }) => { + await page.setViewportSize({ width: 1400, height: 900 }) + await mockOpenCodeServer(page, { + protocol: "v2", + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "hidden-terminal-regression", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [ + { + id: sessionID, + slug: "hidden-terminal-regression", + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + pageMessages: () => ({ items: [] }), + }) + await page.route("**/api/pty*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory } }, + data: { + id: "pty_hidden_terminal", + title: "Terminal 1", + command: "cmd.exe", + args: [], + cwd: directory, + status: "running", + pid: 1, + }, + }), + }), + ) + await page.route("**/api/pty/pty_hidden_terminal*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory } }, + data: { + id: "pty_hidden_terminal", + title: "Terminal 1", + command: "cmd.exe", + args: [], + cwd: directory, + status: "running", + pid: 1, + }, + }), + }), + ) + await page.route("**/api/pty/pty_hidden_terminal/connect-token*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory } }, + data: { ticket: "e2e-ticket", expires_in: 60 }, + }), + }), + ) + await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined) + + await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + await page.keyboard.press("Control+Backquote") + const panel = page.locator("#terminal-panel") + await expect(panel).toHaveAttribute("aria-hidden", "false") + await expect(page.locator('[data-component="terminal"]')).toBeVisible() + + await page.keyboard.press("Control+Backquote") + await expect(panel).toHaveCount(0) + await expect(page.locator('[data-component="terminal"]')).toHaveCount(0) + + await page.setViewportSize({ width: 1200, height: 700 }) + await expect(page.locator('[data-component="terminal"]')).toHaveCount(0) + + await page.keyboard.press("Control+Backquote") + await expect(panel).toBeVisible() + await expect(page.locator('[data-component="terminal"]')).toBeVisible() +}) + +function base64Encode(value: string) { + return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") +} diff --git a/packages/app/e2e/regression/terminal-tab-switch.spec.ts b/packages/app/e2e/regression/terminal-tab-switch.spec.ts new file mode 100644 index 000000000000..165920753cb2 --- /dev/null +++ b/packages/app/e2e/regression/terminal-tab-switch.spec.ts @@ -0,0 +1,165 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/TerminalTabSwitch" +const projectID = "proj_terminal_tab_switch" +const sessionA = "ses_terminal_tab_a" +const sessionB = "ses_terminal_tab_b" +const titleA = "Alpha session" +const titleB = "Beta session" +const ptyID = "pty_tab_switch" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` +// Marks the terminal DOM node so a remount (fresh node) is detectable. +const PROBE = "original" + +test.use({ viewport: { width: 1440, height: 900 } }) + +// Terminals are workspace-scoped: switching between session tabs in the same +// workspace must keep the terminal mounted and its PTY connection open instead +// of tearing it down and reconnecting. +test("keeps the terminal session alive when switching session tabs in a workspace", async ({ page }) => { + const connections = await setup(page) + + await page.goto(sessionHref(sessionA)) + await expectSessionTitle(page, titleA) + + await page.keyboard.press("Control+Backquote") + const terminal = page.locator('[data-component="terminal"]') + await expect(terminal).toBeVisible() + await expect.poll(() => connections.length).toBe(1) + const connection = new URL(connections[0]!) + expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`) + expect(connection.searchParams.get("location[directory]")).toBe(directory) + expect(connection.searchParams.get("ticket")).toBeNull() + await writeProbe(page) + + await switchTab(page, titleB) + await expectSessionTitle(page, titleB) + await expect(terminal).toBeVisible() + expect(await readProbe(page)).toBe(PROBE) + expect(connections.length).toBe(1) + + await switchTab(page, titleA) + await expectSessionTitle(page, titleA) + await expect(terminal).toBeVisible() + expect(await readProbe(page)).toBe(PROBE) + expect(connections.length).toBe(1) +}) + +type Probed = HTMLElement & { __e2eProbe?: string } + +async function switchTab(page: Page, title: string) { + await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click() +} + +async function writeProbe(page: Page) { + await page.locator('[data-component="terminal"]').evaluate((el, probe) => { + ;(el as Probed).__e2eProbe = probe + }, PROBE) +} + +async function readProbe(page: Page) { + return page.locator('[data-component="terminal"]').evaluate((el) => (el as Probed).__e2eProbe) +} + +async function setup(page: Page) { + await mockOpenCodeServer(page, { + protocol: "v2", + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "terminal-tab-switch", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], + pageMessages: () => ({ items: [] }), + }) + await page.route("**/api/pty*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }), + }), + ) + await page.route(`**/api/pty/${ptyID}*`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }), + }), + ) + await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => { + expect(route.request().headers()["x-opencode-ticket"]).toBe("1") + const url = new URL(route.request().url()) + expect(url.searchParams.get("location[directory]")).toBe(directory) + return route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), + }) + }) + const connections: string[] = [] + await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => { + connections.push(ws.url()) + }) + + await page.addInitScript( + ({ directory, server, sessions }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))), + ) + }, + { directory, server, sessions: [sessionA, sessionB] }, + ) + return connections +} + +function session(id: string, title: string, created: number) { + return { + id, + slug: id, + projectID, + directory, + title, + version: "dev", + time: { created, updated: created }, + } +} + +function sessionHref(sessionID: string) { + return `/server/${base64Encode(server)}/session/${sessionID}` +} + +function ptyLocation() { + return { directory, project: { id: projectID, directory } } +} + +function ptyInfo() { + return { id: ptyID, title: "Terminal 1", command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 } +} diff --git a/packages/app/e2e/reproduction/timeline-suspense/index.html b/packages/app/e2e/reproduction/timeline-suspense/index.html new file mode 100644 index 000000000000..995008a9f7a6 --- /dev/null +++ b/packages/app/e2e/reproduction/timeline-suspense/index.html @@ -0,0 +1,34 @@ + + + + + + Timeline Suspense Reproduction + + + +
+ + + diff --git a/packages/app/e2e/reproduction/timeline-suspense/main.tsx b/packages/app/e2e/reproduction/timeline-suspense/main.tsx new file mode 100644 index 000000000000..cdcd809be3be --- /dev/null +++ b/packages/app/e2e/reproduction/timeline-suspense/main.tsx @@ -0,0 +1,315 @@ +import { createResource, createSignal, For, onMount, Suspense } from "solid-js" +import { render } from "solid-js/web" +import { createVirtualizer, observeElementOffset, observeElementRect } from "@tanstack/solid-virtual" +import { observeElementOffsetReconnectAware } from "../../../src/pages/session/timeline/observe-element-offset" + +const rowCount = 2_000 +const rowHeight = 40 +const parameters = new URLSearchParams(location.search) +const resourceMode = parameters.get("resource") === "guard" ? "guard" : "baseline" +const reconnectMode = parameters.get("reconnect") === "candidate" ? "candidate" : "baseline" + +type MutationEvent = { + kind: "removed" | "added" + callbackTime: number + callbackFrame: number + routeConnectedInCallback: boolean + nativeOffsetInCallback: number +} + +type Snapshot = { + mode: { + resource: "baseline" | "guard" + reconnect: "baseline" | "candidate" + } + operation: { + sequence: number + phase: string + time: number + frame: number + } + resourceState: string + routeConnected: boolean + viewportConnected: boolean + viewportOwnedByRoute: boolean + sameRoute: boolean + sameViewport: boolean + sameSurface: boolean + sameMountedRows: boolean + nativeOffset: number + coreOffset: number + rangeStart: number + rangeEnd: number + indexes: number[] + domIndexes: number[] + logicalSurfaceHeight: number + renderedSurfaceHeight: number + viewportClientHeight: number + viewportScrollHeight: number + visibleRows: number + minimumRowTop: number + domScrollEvents: number + lastScrollTrusted: boolean + coreOffsetCallbackCalls: number + offsetCallbackSources: "observer"[] + rectObserverCallbacks: number + ignoredDetachedZeroRects: number + syntheticScrollDispatches: number + mutationEvents: MutationEvent[] +} + +declare global { + interface Window { + timelineSuspense: { + prepare: () => Promise + trigger: () => void + resolve: () => void + frames: (count?: number) => Promise + snapshot: () => Snapshot + } + } +} + +function App() { + const [refresh, setRefresh] = createSignal(false) + let resolveResource: (() => void) | undefined + const [resource] = createResource( + refresh, + (version) => + new Promise((resolve) => { + resolveResource = () => resolve(`settled-${version}`) + }), + { initialValue: "settled" }, + ) + + function Route() { + let route: HTMLElement | undefined + let viewport: HTMLDivElement | undefined + let surface: HTMLDivElement | undefined + let initialRoute: HTMLElement | undefined + let initialViewport: HTMLDivElement | undefined + let initialSurface: HTMLDivElement | undefined + let initialRows: HTMLElement[] = [] + let phase = "mounting" + let browserFrame = 0 + let snapshotSequence = 0 + let domScrollEvents = 0 + let lastScrollTrusted = false + let coreOffsetCallbackCalls = 0 + let rectObserverCallbacks = 0 + let ignoredDetachedZeroRects = 0 + const offsetCallbackSources: "observer"[] = [] + const mutationEvents: MutationEvent[] = [] + const virtualizer = createVirtualizer({ + count: rowCount, + getScrollElement: () => viewport ?? null, + estimateSize: () => rowHeight, + initialRect: { width: 900, height: 600 }, + overscan: 2, + observeElementRect: (instance, callback) => + observeElementRect(instance, (rect) => { + rectObserverCallbacks++ + // A fixed 600px viewport has no usable geometry while detached. Keep the last connected rect. + if (!instance.scrollElement?.isConnected && rect.height === 0) { + ignoredDetachedZeroRects++ + return + } + callback(rect) + }), + observeElementOffset: (instance, callback) => { + const deliver = (offset: number, isScrolling: boolean) => { + coreOffsetCallbackCalls++ + offsetCallbackSources.push("observer") + callback(offset, isScrolling) + } + if (reconnectMode === "candidate") return observeElementOffsetReconnectAware(instance, deliver) + return observeElementOffset(instance, deliver) + }, + }) + + const frames = async (count = 2) => { + for (let index = 0; index < count; index++) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + } + } + const mountedRows = () => [...(surface?.querySelectorAll("[data-row-index]") ?? [])] + const snapshot = (): Snapshot => { + const rows = mountedRows() + const view = viewport?.getBoundingClientRect() + const visibleRows = + viewport?.isConnected && view + ? rows.filter((row) => { + const rect = row.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }).length + : 0 + return { + mode: { resource: resourceMode, reconnect: reconnectMode }, + operation: { + sequence: ++snapshotSequence, + phase, + time: performance.now(), + frame: browserFrame, + }, + resourceState: resource.state, + routeConnected: route?.isConnected ?? false, + viewportConnected: viewport?.isConnected ?? false, + viewportOwnedByRoute: !!route && !!viewport && route.contains(viewport), + sameRoute: route === initialRoute, + sameViewport: viewport === initialViewport, + sameSurface: surface === initialSurface, + sameMountedRows: + initialRows.length > 0 && + initialRows.length === rows.length && + initialRows.every((row, index) => row === rows[index]), + nativeOffset: viewport?.scrollTop ?? -1, + coreOffset: virtualizer.scrollOffset ?? -1, + rangeStart: virtualizer.range?.startIndex ?? -1, + rangeEnd: virtualizer.range?.endIndex ?? -1, + indexes: virtualizer.getVirtualItems().map((item) => item.index), + domIndexes: rows.map((row) => Number(row.dataset.rowIndex)), + logicalSurfaceHeight: Number.parseFloat(surface?.style.height ?? "-1"), + renderedSurfaceHeight: surface?.getBoundingClientRect().height ?? -1, + viewportClientHeight: viewport?.clientHeight ?? -1, + viewportScrollHeight: viewport?.scrollHeight ?? -1, + visibleRows, + minimumRowTop: + rows.length && view ? Math.min(...rows.map((row) => row.getBoundingClientRect().top - view.top)) : -1, + domScrollEvents, + lastScrollTrusted, + coreOffsetCallbackCalls, + offsetCallbackSources: [...offsetCallbackSources], + rectObserverCallbacks, + ignoredDetachedZeroRects, + syntheticScrollDispatches: 0, + mutationEvents: mutationEvents.map((event) => ({ ...event })), + } + } + + onMount(() => { + if (!route || !viewport || !surface) throw new Error("Timeline fixture did not mount") + const routeRoot = route.parentElement + if (!routeRoot) throw new Error("Timeline route root did not mount") + initialRoute = route + initialViewport = viewport + initialSurface = surface + viewport.addEventListener("scroll", (event) => { + domScrollEvents++ + lastScrollTrusted = event.isTrusted + }) + const countFrames = () => { + browserFrame++ + requestAnimationFrame(countFrames) + } + requestAnimationFrame(countFrames) + new MutationObserver((records) => { + const callbackTime = performance.now() + records.forEach((record) => { + ;([...(record.removedNodes ?? [])] as Node[]).forEach((node) => { + if (node !== route) return + phase = "detached" + mutationEvents.push({ + kind: "removed", + callbackTime, + callbackFrame: browserFrame, + routeConnectedInCallback: route.isConnected, + nativeOffsetInCallback: viewport.scrollTop, + }) + }) + ;([...(record.addedNodes ?? [])] as Node[]).forEach((node) => { + if (node !== route) return + phase = "reinserted" + mutationEvents.push({ + kind: "added", + callbackTime, + callbackFrame: browserFrame, + routeConnectedInCallback: route.isConnected, + nativeOffsetInCallback: viewport.scrollTop, + }) + }) + }) + }).observe(routeRoot, { childList: true }) + window.timelineSuspense = { + prepare: async () => { + phase = "preparing" + await frames(2) + viewport.scrollTop = viewport.scrollHeight + await frames(3) + await new Promise((resolve) => setTimeout(resolve, 200)) + await frames(2) + initialRows = mountedRows() + phase = "prepared" + return snapshot() + }, + trigger: () => { + phase = "triggering" + setRefresh(true) + }, + resolve: () => { + if (!resolveResource) throw new Error("Resource is not pending") + phase = "resolving" + resolveResource() + }, + frames, + snapshot, + } + }) + + return ( +
+ +
+
+ + {(item) => ( +
+ logical row {item.index} +
+ )} +
+
+
+
+ ) + } + + return ( +
+ + + +
+ ) +} + +render(() => , document.getElementById("root")!) diff --git a/packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts b/packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts new file mode 100644 index 000000000000..7fff1737aef1 --- /dev/null +++ b/packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, devices } from "@playwright/test" + +const port = Number(process.env.PLAYWRIGHT_TIMELINE_SUSPENSE_PORT ?? 4317) + +export default defineConfig({ + testDir: ".", + testMatch: "timeline-suspense.repro.ts", + outputDir: "../../test-results/timeline-suspense", + fullyParallel: false, + workers: 1, + retries: 0, + reporter: "line", + timeout: 30_000, + expect: { + timeout: 10_000, + }, + webServer: { + command: `bunx vite --config vite.config.ts --host 127.0.0.1 --port ${port} --strictPort`, + cwd: import.meta.dirname, + url: `http://127.0.0.1:${port}`, + reuseExistingServer: false, + }, + use: { + baseURL: `http://127.0.0.1:${port}`, + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}) diff --git a/packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts b/packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts new file mode 100644 index 000000000000..3edae33a9ba6 --- /dev/null +++ b/packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts @@ -0,0 +1,179 @@ +import { expect, test, type Page } from "@playwright/test" + +test.beforeEach(async ({ page }) => { + page.on("pageerror", (error) => console.error(error)) + await page.goto("/") + await expect.poll(() => page.evaluate(() => !!window.timelineSuspense)).toBe(true) +}) + +test("desired: preserves visible timeline continuity across descendant resource suspension", async ({ page }) => { + await page.goto("/?reconnect=candidate") + await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.reconnect)).toBe("candidate") + const before = await prepare(page) + await triggerBaselineSuspension(page) + const pending = await page.evaluate(() => window.timelineSuspense.snapshot()) + expect(pending.nativeOffset).toBe(0) + expect(pending.coreOffset).toBe(before.coreOffset) + expect(pending.indexes).toEqual(before.indexes) + expect(pending.sameRoute).toBe(true) + expect(pending.sameViewport).toBe(true) + expect(pending.sameSurface).toBe(true) + expect(pending.sameMountedRows).toBe(true) + + await resolveSuspension(page) + await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().coreOffset)).toBe(0) + await page.waitForTimeout(250) + await page.evaluate(() => window.timelineSuspense.frames(2)) + const after = await page.evaluate(() => window.timelineSuspense.snapshot()) + expect(after.sameRoute).toBe(true) + expect(after.sameViewport).toBe(true) + expect(after.sameSurface).toBe(true) + expect(after.nativeOffset).toBe(0) + expect(after.coreOffset).toBe(0) + expect(after.rangeStart).toBeLessThan(10) + expect(after.visibleRows, diagnostic({ before, pending, after })).toBeGreaterThan(0) + expect(after.domScrollEvents).toBe(before.domScrollEvents) + expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls + 1) + expect(after.offsetCallbackSources.at(-1)).toBe("observer") + expect(after.syntheticScrollDispatches).toBe(0) +}) + +test("forensic: proves detached same-node viewport leaves TanStack's bottom range blank until a real scroll", async ({ + page, +}) => { + const before = await prepare(page) + const beforeRows = before.domIndexes + expect(before.mode).toEqual({ resource: "baseline", reconnect: "baseline" }) + expect(before.logicalSurfaceHeight).toBe(80_000) + expect(before.renderedSurfaceHeight).toBe(80_000) + expect(before.viewportClientHeight).toBe(600) + expect(before.viewportScrollHeight).toBe(80_000) + expect(before.rangeStart).toBeGreaterThan(1_900) + expect(before.nativeOffset).toBe(before.coreOffset) + expect(before.visibleRows).toBeGreaterThan(0) + + await triggerBaselineSuspension(page) + const pending = await page.evaluate(() => window.timelineSuspense.snapshot()) + expect(pending.resourceState).toBe("refreshing") + expect(pending.routeConnected).toBe(false) + expect(pending.viewportConnected).toBe(false) + expect(pending.viewportOwnedByRoute).toBe(true) + expect(pending.nativeOffset).toBe(0) + expect(pending.coreOffset).toBe(before.coreOffset) + expect(pending.rangeStart).toBe(before.rangeStart) + expect(pending.rangeEnd).toBe(before.rangeEnd) + expect(pending.indexes).toEqual(before.indexes) + expect(pending.domIndexes).toEqual(beforeRows) + expect(pending.sameMountedRows).toBe(true) + expect(pending.domScrollEvents).toBe(before.domScrollEvents) + expect(pending.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls) + expect(pending.ignoredDetachedZeroRects).toBeGreaterThan(before.ignoredDetachedZeroRects) + expect(pending.mutationEvents).toHaveLength(1) + expect(pending.mutationEvents[0]).toMatchObject({ + kind: "removed", + routeConnectedInCallback: false, + nativeOffsetInCallback: 0, + }) + expect(pending.mutationEvents[0]!.callbackTime).toBeLessThanOrEqual(pending.operation.time) + expect(pending.mutationEvents[0]!.callbackFrame).toBeLessThanOrEqual(pending.operation.frame) + + const after = await resolveSuspension(page) + expect(after.resourceState).toBe("ready") + expect(after.routeConnected).toBe(true) + expect(after.viewportConnected).toBe(true) + expect(after.viewportOwnedByRoute).toBe(true) + expect(after.sameRoute).toBe(true) + expect(after.sameViewport).toBe(true) + expect(after.sameSurface).toBe(true) + expect(after.sameMountedRows).toBe(true) + expect(after.nativeOffset).toBe(0) + expect(after.coreOffset).toBe(before.coreOffset) + expect(after.rangeStart).toBe(before.rangeStart) + expect(after.rangeEnd).toBe(before.rangeEnd) + expect(after.indexes).toEqual(before.indexes) + expect(after.domIndexes).toEqual(beforeRows) + expect(after.domScrollEvents).toBe(before.domScrollEvents) + expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls) + expect(after.mutationEvents).toHaveLength(2) + expect(after.mutationEvents[1]).toMatchObject({ + kind: "added", + routeConnectedInCallback: true, + nativeOffsetInCallback: 0, + }) + expect(after.mutationEvents[1]!.callbackTime).toBeLessThanOrEqual(after.operation.time) + expect(after.mutationEvents[1]!.callbackFrame).toBeLessThanOrEqual(after.operation.frame) + expect(after.visibleRows).toBe(0) + expect(after.minimumRowTop).toBeGreaterThan(50_000) + expect(after.syntheticScrollDispatches).toBe(0) + + await page.locator("[data-viewport]").hover() + await page.mouse.wheel(0, 80) + await expect + .poll(() => + page.evaluate(() => { + const value = window.timelineSuspense.snapshot() + return value.nativeOffset > 0 && value.coreOffset === value.nativeOffset + }), + ) + .toBe(true) + await page.evaluate(() => window.timelineSuspense.frames(2)) + const recovered = await page.evaluate(() => window.timelineSuspense.snapshot()) + expect(recovered.domScrollEvents).toBeGreaterThan(after.domScrollEvents) + expect(recovered.coreOffsetCallbackCalls).toBeGreaterThan(after.coreOffsetCallbackCalls) + expect(recovered.offsetCallbackSources.at(-1)).toBe("observer") + expect(recovered.lastScrollTrusted).toBe(true) + expect(recovered.rangeStart).toBeLessThan(10) + expect(recovered.visibleRows).toBeGreaterThan(0) +}) + +test("matrix: fixture-only settled-resource guard keeps the route connected", async ({ page }) => { + await page.goto("/?resource=guard") + await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.resource)).toBe("guard") + const before = await prepare(page) + + await page.evaluate(() => window.timelineSuspense.trigger()) + await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing") + await page.evaluate(() => window.timelineSuspense.frames(3)) + const pending = await page.evaluate(() => window.timelineSuspense.snapshot()) + expect(pending.routeConnected).toBe(true) + expect(pending.mutationEvents).toEqual([]) + expect(pending.nativeOffset).toBe(before.nativeOffset) + expect(pending.coreOffset).toBe(before.coreOffset) + expect(pending.visibleRows).toBeGreaterThan(0) + + const after = await resolveSuspension(page) + expect(after.routeConnected).toBe(true) + expect(after.nativeOffset).toBe(before.nativeOffset) + expect(after.coreOffset).toBe(before.coreOffset) + expect(after.visibleRows).toBeGreaterThan(0) +}) + +async function prepare(page: Page) { + const before = await page.evaluate(() => window.timelineSuspense.prepare()) + expect(before.routeConnected).toBe(true) + expect(before.viewportConnected).toBe(true) + expect(before.viewportOwnedByRoute).toBe(true) + expect(before.sameMountedRows).toBe(true) + expect(before.rangeStart).toBeGreaterThan(1_900) + expect(before.nativeOffset).toBe(before.coreOffset) + return before +} + +async function triggerBaselineSuspension(page: Page) { + await page.evaluate(() => window.timelineSuspense.trigger()) + await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing") + await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(false) + await page.evaluate(() => window.timelineSuspense.frames(3)) +} + +async function resolveSuspension(page: Page) { + await page.evaluate(() => window.timelineSuspense.resolve()) + await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("ready") + await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(true) + await page.evaluate(() => window.timelineSuspense.frames(3)) + return page.evaluate(() => window.timelineSuspense.snapshot()) +} + +function diagnostic(value: unknown) { + return JSON.stringify(value, null, 2) +} diff --git a/packages/app/e2e/reproduction/timeline-suspense/vite.config.ts b/packages/app/e2e/reproduction/timeline-suspense/vite.config.ts new file mode 100644 index 000000000000..efb75ab731c6 --- /dev/null +++ b/packages/app/e2e/reproduction/timeline-suspense/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vite" +import solid from "vite-plugin-solid" + +export default defineConfig({ + root: import.meta.dirname, + plugins: [solid()], +}) diff --git a/packages/app/e2e/smoke/session-timeline.fixture.ts b/packages/app/e2e/smoke/session-timeline.fixture.ts index 1fc8571db44f..3dce37cafd9d 100644 --- a/packages/app/e2e/smoke/session-timeline.fixture.ts +++ b/packages/app/e2e/smoke/session-timeline.fixture.ts @@ -21,6 +21,7 @@ const words = [ "vector", ] +const serverKey = "http://127.0.0.1:4096" const sourceID = "ses_smoke_source" const targetID = "ses_smoke_target" const directory = "C:/OpenCode/SmokeProject" @@ -139,7 +140,7 @@ function toolPart( status: "completed", input, output: lorem(index * 23 + partIndex, outputLength), - title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed", + title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed", metadata, time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 }, }, @@ -200,9 +201,7 @@ function turn(index: number): Message[] { ...(index % 8 === 0 ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] : []), - ...(index % 7 === 0 - ? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)] - : []), + ...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []), ...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []), ...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []), ...(index % 13 === 0 @@ -242,6 +241,7 @@ function orderedParts(message: Message) { export const fixture = { directory, + serverKey, project: { id: projectID, worktree: directory, @@ -295,6 +295,7 @@ export const fixture = { .filter(renderable) .map((part) => part.id), ), + expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id, }, } diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index 5d7425ea5bb5..bdf3f55bdc10 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -30,6 +30,295 @@ type SmokeWindow = Window & { test.describe("smoke: session timeline", () => { test.setTimeout(240_000) + test("keeps the visible message fixed while prepending history", async ({ page }) => { + const requests: { before?: string; phase: "start" | "end"; at: number }[] = [] + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + messageDelay: 3_000, + onMessages: (input) => requests.push({ before: input.before, phase: input.phase, at: performance.now() }), + }) + await configureSmokePage(page, fixture.directory) + + await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle) + await waitForTimelineStable(page) + const scroller = timelineScroller(page) + await pointAtTimeline(page) + const deadline = Date.now() + 120_000 + while (!requests.some((request) => request.before && request.phase === "start")) { + if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary") + await page.mouse.wheel(0, -240) + await page.waitForTimeout(20) + } + expect(requests.some((request) => request.before && request.phase === "end")).toBe(false) + for (let index = 0; index < 12; index++) { + await page.mouse.wheel(0, -120) + await page.waitForTimeout(20) + } + const keys = await scroller.evaluate((element) => { + const view = element.getBoundingClientRect() + return [...element.querySelectorAll("[data-timeline-part-id]")] + .filter((row) => { + const rect = row.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }) + .map((row) => row.dataset.timelinePartId) + .filter((id): id is string => !!id) + .slice(0, 3) + }) + expect(keys.length).toBeGreaterThan(0) + const positions = () => + scroller.evaluate((element, keys) => { + const top = element.getBoundingClientRect().top + return Object.fromEntries( + keys.map((key) => { + const row = element.querySelector(`[data-timeline-part-id="${key}"]`) + if (!row) throw new Error(`Missing stable timeline key: ${key}`) + return [key, Math.round((row.getBoundingClientRect().top - top) * devicePixelRatio) / devicePixelRatio] + }), + ) + }, keys) + const before = await positions() + expect(requests.some((request) => request.before && request.phase === "end")).toBe(false) + + await expect.poll(() => requests.some((request) => request.before && request.phase === "end")).toBe(true) + await waitForTimelineStable(page) + await expect.poll(positions).toEqual(before) + }) + + test("preserves the timeline gap above the composer", async ({ page }) => { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + await configureSmokePage(page, fixture.directory) + + await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle) + await waitForTimelineStable(page) + const scroller = timelineScroller(page) + await scroller.evaluate((element) => { + element.scrollTop = element.scrollHeight + }) + await waitForTimelineStable(page) + + const spacer = scroller.locator('[data-timeline-row="bottom-spacer"]') + await expect(spacer).toBeVisible() + expect(await spacer.evaluate((element) => element.getBoundingClientRect().height)).toBe(64) + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1) + }) + + test("paints cached session tabs at the latest message", async ({ page }) => { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }), + }) + await configureSmokePage(page, fixture.directory) + await page.addInitScript( + ({ dirBase64, sourceID, targetID }) => { + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify( + [sourceID, targetID].map((sessionId) => ({ + type: "session", + server: "http://127.0.0.1:4096", + dirBase64, + sessionId, + })), + ), + ) + }, + { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID }, + ) + + await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`) + await expectSessionTitle(page, fixture.expected.targetTitle) + await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle) + + const destination = fixture.messages[fixture.targetID].map((message) => message.info.id) + const last = fixture.expected.targetMessageIDs.at(-1)! + await page.evaluate( + ({ destination, last }) => { + const ids = new Set(destination) + const samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> = [] + const firstPaintNodes = new WeakSet() + let firstPaint = false + let removedFirstPaintNodes = 0 + let running = true + new MutationObserver((records) => { + if (!firstPaint || !running) return + records.forEach((record) => + record.removedNodes.forEach((node) => { + if (firstPaintNodes.has(node)) removedFirstPaintNodes += 1 + if (!(node instanceof Element)) return + node.querySelectorAll("*").forEach((element) => { + if (firstPaintNodes.has(element)) removedFirstPaintNodes += 1 + }) + }), + ) + }).observe(document.documentElement, { childList: true, subtree: true }) + const sample = () => { + if (!running) return + setTimeout(() => { + if (!running) return + const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => + element.querySelector("[data-timeline-row]"), + ) + if (root) { + const view = root.getBoundingClientRect() + const visible = [...root.querySelectorAll("[data-message-id]")] + .filter((element) => { + const rect = element.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }) + .map((element) => element.dataset.messageId!) + .filter((id) => ids.has(id)) + const bottom = root + .querySelector('[data-timeline-row="bottom-spacer"]') + ?.getBoundingClientRect() + samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom }) + if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) { + firstPaint = true + root.querySelectorAll("[data-timeline-key]").forEach((row) => { + const rect = row.getBoundingClientRect() + if (rect.bottom <= view.top || rect.top >= view.bottom) return + firstPaintNodes.add(row) + row.querySelectorAll("*").forEach((element) => firstPaintNodes.add(element)) + }) + } + } + requestAnimationFrame(sample) + }, 0) + } + ;( + window as Window & { + __sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void } + } + ).__sessionTabPaint = { + samples, + removed: () => removedFirstPaintNodes, + stop: () => { + running = false + }, + } + requestAnimationFrame(sample) + }, + { destination, last }, + ) + + await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle) + await page.waitForFunction(() => + ( + window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } } + ).__sessionTabPaint?.samples.some((sample) => sample.ids.length > 0), + ) + await page.waitForTimeout(200) + const first = await page.evaluate(() => { + const probe = ( + window as Window & { + __sessionTabPaint?: { + samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> + removed: () => number + stop: () => void + } + } + ).__sessionTabPaint! + probe.stop() + return { first: probe.samples.find((sample) => sample.ids.length > 0), removed: probe.removed() } + }) + expect(first.first?.last).toBe(true) + expect(Math.abs(first.first?.bottomError ?? Infinity)).toBeLessThanOrEqual(1) + expect(first.removed).toBe(0) + }) + + test("paints a cold session tab at the latest message", async ({ page }) => { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }), + }) + await configureSmokePage(page, fixture.directory) + await page.addInitScript( + ({ dirBase64, sourceID, targetID }) => { + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify( + [sourceID, targetID].map((sessionId) => ({ + type: "session", + server: "http://127.0.0.1:4096", + dirBase64, + sessionId, + })), + ), + ) + }, + { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID }, + ) + await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`) + await expectSessionTitle(page, fixture.expected.sourceTitle) + const last = fixture.expected.targetMessageIDs.at(-1)! + const destination = fixture.messages[fixture.targetID].map((message) => message.info.id) + await page.evaluate( + ({ destination, last }) => { + const ids = new Set(destination) + const samples: Array<{ destination: boolean; last: boolean; bottomError?: number }> = [] + const sample = () => { + const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => + element.querySelector("[data-timeline-row]"), + ) + if (root) { + const view = root.getBoundingClientRect() + const spacer = root + .querySelector('[data-timeline-row="bottom-spacer"]') + ?.getBoundingClientRect() + const messages = [...root.querySelectorAll("[data-message-id]")].filter((element) => { + const rect = element.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }) + samples.push({ + destination: messages.some((element) => ids.has(element.dataset.messageId!)), + last: messages.some((element) => element.dataset.messageId === last), + bottomError: spacer ? spacer.bottom - view.bottom : undefined, + }) + } + requestAnimationFrame(() => setTimeout(sample, 0)) + } + ;(window as Window & { __coldTabSamples?: typeof samples }).__coldTabSamples = samples + requestAnimationFrame(() => setTimeout(sample, 0)) + }, + { destination, last }, + ) + + await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle) + await page.waitForFunction(() => + (window as Window & { __coldTabSamples?: Array<{ destination: boolean }> }).__coldTabSamples?.some( + (sample) => sample.destination, + ), + ) + const result = await page.evaluate(() => { + const samples = ( + window as Window & { + __coldTabSamples?: Array<{ destination: boolean; last: boolean; bottomError?: number }> + } + ).__coldTabSamples! + return samples.find((sample) => sample.destination)! + }) + expect(result.last).toBe(true) + expect(Math.abs(result.bottomError ?? Infinity)).toBeLessThanOrEqual(1) + }) + test("renders seeded timeline in order while paging through history", async ({ page }) => { const errors = trackPageErrors(page) await mockOpenCodeServer(page, { @@ -49,6 +338,18 @@ test.describe("smoke: session timeline", () => { const expectedMessageIDs = fixture.expected.targetMessageIDs await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors) await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors) + + const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`) + const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]') + const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]') + await expect(shellSubtitle).toHaveCount(0) + await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck") + await shellTrigger.click() + await expect(shellTrigger).toHaveAttribute("aria-expanded", "false") + await expect(shellSubtitle).toHaveText("bun typecheck") + await shellTrigger.click() + await expect(shellTrigger).toHaveAttribute("aria-expanded", "true") + await expect(shellSubtitle).toHaveCount(0) }) }) @@ -61,7 +362,6 @@ async function configureSmokePage(page: Page, directory: string) { editToolPartsExpanded: true, shellToolPartsExpanded: true, showReasoningSummaries: true, - showSessionProgressBar: true, }, }), ) @@ -427,6 +727,14 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin await expectSessionTitle(page, expectedTitle) } +async function switchTitlebarSession(page: Page, sessionID: string, title: string) { + const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}` + const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first() + await expect(tab).toBeVisible() + await tab.click() + await expectSessionTitle(page, title) +} + async function expectSessionReady(page: Page) { - await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i })) + await expectAppVisible(page.getByRole("textbox", { name: "Prompt" })) } diff --git a/packages/app/e2e/tsconfig.json b/packages/app/e2e/tsconfig.json index 3f1cad80cb60..4a6046e4faaf 100644 --- a/packages/app/e2e/tsconfig.json +++ b/packages/app/e2e/tsconfig.json @@ -5,5 +5,16 @@ "rootDir": "..", "types": ["node", "bun"] }, - "include": ["./**/*.ts"] + "include": [ + "./performance/timeline-stability/**/*.spec.ts", + "./performance/timeline-stability/fixture.test.ts", + "./performance/timeline-stability/fixture.ts", + "./performance/unit/visual-stability.test.ts", + "./reproduction/timeline-suspense/**/*.ts", + "./reproduction/timeline-suspense/**/*.tsx", + "../src/pages/session/timeline/observe-element-offset.ts", + "./regression/new-session-panel-corner.spec.ts", + "./regression/session-timeline-context-resize.spec.ts", + "./utils/**/*.ts" + ] } diff --git a/packages/app/e2e/user-story/model-selection-flow.spec.ts b/packages/app/e2e/user-story/model-selection-flow.spec.ts new file mode 100644 index 000000000000..22b8bb41fe25 --- /dev/null +++ b/packages/app/e2e/user-story/model-selection-flow.spec.ts @@ -0,0 +1,97 @@ +import { expect, test } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +const directory = "C:/OpenCode/NewProject" + +test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => { + let connectedGo = false + let pendingGo = false + const connections: Array<{ integrationID: string; body: unknown }> = [] + + await mockOpenCodeServer(page, { + directory, + project: { + id: "proj_model_selection_flow", + worktree: directory, + vcs: "git", + name: "NewProject", + time: { created: 1_700_000_000_000, updated: 1_700_000_000_000 }, + sandboxes: [], + }, + provider: () => ({ + all: [ + { + id: "opencode", + name: "OpenCode", + models: { + "free-model": { + id: "free-model", + name: "Free Model", + cost: { input: 0, output: 0 }, + limit: { context: 200_000 }, + }, + }, + }, + { + id: "opencode-go", + name: "OpenCode Go", + models: { + "go-model-1": { + id: "go-model-1", + name: "Go Model 1", + cost: { input: 1, output: 1 }, + limit: { context: 200_000 }, + }, + }, + }, + ], + connected: connectedGo ? ["opencode", "opencode-go"] : ["opencode"], + default: { providerID: "opencode", modelID: "free-model" }, + }), + integrationMethods: { "opencode-go": [{ type: "api", label: "API key" }] }, + onConnectKey: (input) => { + connections.push(input) + if (input.integrationID === "opencode-go") pendingGo = true + }, + onInstanceDispose: () => { + if (pendingGo) connectedGo = true + }, + sessions: [], + pageMessages: () => ({ items: [] }), + fileList: (path) => + path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }], + findFiles: () => ["NewProject"], + }) + await page.addInitScript(() => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } })) + }) + + await page.goto("/") + const addProject = page.locator('[data-action="home-add-project-row"]') + await expectAppVisible(addProject) + await addProject.click() + await page.locator("[data-directory-path]").click() + + await page.locator('[data-action="home-new-session"]').click() + await expectAppVisible(page.locator('[data-component="prompt-input-v2"]')) + + const modelControl = page.locator('[data-action="prompt-model"]') + await modelControl.click() + await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode") + + await page.locator('[data-provider-id="opencode-go"]').click() + await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key") + await page.locator('[data-action="provider-connect-submit"]').click() + await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0) + expect(connections).toEqual([{ integrationID: "opencode-go", body: { type: "api", key: "mock-go-api-key" } }]) + + await expect(modelControl).toHaveAttribute("data-control-type", "popover") + await modelControl.click() + const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]') + await expect(goModel).toBeVisible() + await goModel.click() + + await expect(modelControl).toContainText("Go Model 1") +}) diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 9a03a9d5adb1..76987421b607 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -1,29 +1,39 @@ import type { Page, Route } from "@playwright/test" -const emptyList = new Set([ - "/skill", - "/command", - "/lsp", - "/formatter", - "/permission", - "/question", - "/vcs/status", - "/vcs/diff", -]) -const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"]) +const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"]) +const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"]) export interface MockServerConfig { - provider: unknown + protocol?: "v1" | "v2" + provider: unknown | (() => unknown) + integrationMethods?: Record + onConnectKey?: (input: { integrationID: string; body: unknown }) => void + onInstanceDispose?: () => void directory: string project: unknown sessions: ({ id: string } & Record)[] pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string } + vcsDiff?: unknown[] + messageDelay?: number + beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise + onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void + message?: (sessionID: string, messageID: string) => unknown + onMessage?: (input: { sessionID: string; messageID: string }) => void events?: () => unknown[] + eventRetry?: number + todos?: (sessionID: string) => unknown[] + permissions?: unknown[] | (() => unknown[]) + questions?: unknown[] | (() => unknown[]) + fileList?: (path: string) => unknown | Promise + fileContent?: (path: string) => unknown | Promise + findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown + sessionStatus?: Record | (() => Record) } export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { + const cursors = new Map() + let nextCursor = 0 const staticRoutes: Record = { - "/provider": config.provider, "/path": { state: config.directory, config: config.directory, @@ -41,38 +51,381 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { await page.route("**/*", async (route) => { const url = new URL(route.request().url()) const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" - if (url.port !== targetPort) return route.fallback() + const appPort = new URL( + process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`, + ).port + if (url.port !== targetPort && url.port !== appPort) return route.fallback() const path = url.pathname - if (path === "/global/event" || path === "/event") return sse(route, config.events?.()) - if (path === "/global/health") return json(route, { healthy: true }) + if (path === "/global/event" || path === "/event" || path === "/api/event") { + const events = config.events?.() + return sse( + route, + path === "/api/event" + ? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])] + : [ + ...(path === "/global/event" + ? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }] + : []), + ...(events ?? []), + ], + config.eventRetry, + ) + } + if (path === "/global/health") + return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true }) + if (path === "/api/health" && config.protocol === "v2") + return json(route, { healthy: true, version: "2.0.0", pid: 1 }) + if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true }) + if (path === "/provider") + return json(route, typeof config.provider === "function" ? config.provider() : config.provider) + if (path === "/provider/auth") return json(route, config.integrationMethods ?? {}) + const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1] + if (legacyAuth && route.request().method() === "PUT") { + config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() }) + return json(route, true) + } + if (path === "/instance/dispose" && route.request().method() === "POST") { + config.onInstanceDispose?.() + return json(route, true) + } + if (path === "/permission") + return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) + if (path === "/question") + return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? [])) + if (path === "/session/status") + return json( + route, + typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}), + ) + if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff) + if (path === "/file" && config.fileList) + return json(route, await config.fileList(url.searchParams.get("path") ?? "")) + if (path === "/file/content" && config.fileContent) + return json(route, await config.fileContent(url.searchParams.get("path") ?? "")) + if (path === "/find/file" && config.findFiles) + return json( + route, + await config.findFiles({ + query: url.searchParams.get("query") ?? "", + dirs: url.searchParams.get("dirs") ?? undefined, + limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined, + }), + ) + if (path === "/api/reference") + return json(route, { + location: { + directory: config.directory, + project: { id: (config.project as { id?: string }).id, directory: config.directory }, + }, + data: [], + }) + if (path === "/api/agent") + return json(route, { + location: location(config), + data: [ + { + id: "build", + name: "Build", + mode: "primary", + hidden: false, + request: { settings: {}, headers: {}, body: {} }, + permissions: [], + }, + ], + }) + if (path === "/api/command") return json(route, { location: location(config), data: [] }) + if (path === "/api/mcp") return json(route, { location: location(config), data: [] }) + if (path === "/api/mcp/resource") + return json(route, { location: location(config), data: { resources: [], templates: [] } }) + const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1] + if (integration && route.request().method() === "GET") + return json(route, { + location: location(config), + data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] }, + }) + const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1] + if (integrationConnect && route.request().method() === "POST") { + config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() }) + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if (path === "/api/project") return json(route, [config.project]) + if (path === "/api/project/current") + return json(route, { id: (config.project as { id?: string }).id, directory: config.directory }) + if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project) + if (path === "/api/path") + return json(route, { + state: config.directory, + config: config.directory, + worktree: config.directory, + directory: config.directory, + home: "C:/OpenCode", + }) + if (path === "/api/permission/request") + return json(route, { + location: location(config), + data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map( + currentPermission, + ), + }) + if (path === "/api/question/request") + return json(route, { + location: location(config), + data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []), + }) + if (path === "/api/vcs") + return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } }) + if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] }) + if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] }) + if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] }) + if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path)) + return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } }) if (emptyObject.has(path)) return json(route, {}) if (emptyList.has(path)) return json(route, []) + if (path === "/api/session") { + const directory = url.searchParams.get("directory") + const parentID = url.searchParams.get("parentID") + const limit = Number(url.searchParams.get("limit") ?? 50) + const offset = Number(url.searchParams.get("cursor") ?? 0) + const sessions = config.sessions + .filter((session) => !directory || session.directory === directory) + .filter((session) => parentID !== "null" || session.parentID === undefined) + .filter((session) => { + const search = url.searchParams.get("search")?.toLowerCase() + return ( + !search || + String(session.title ?? "") + .toLowerCase() + .includes(search) + ) + }) + const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions + const data = ordered.slice(offset, offset + limit) + const next = offset + limit < ordered.length ? String(offset + limit) : undefined + return json(route, { + data: data.map((session) => currentSession(session, config.directory)), + cursor: { next }, + }) + } + if (path === "/api/session/active") { + const statuses = (config.sessionStatus ?? {}) as Record + return json(route, { + data: Object.fromEntries( + Object.entries(statuses).flatMap(([id, status]) => + status.type === "idle" ? [] : [[id, { type: "running" }]], + ), + ), + }) + } + if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") { + return json(route, true) + } + if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") { + return json(route, true) + } + if ( + /^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) && + route.request().method() === "POST" + ) { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } if (path in staticRoutes) return json(route, staticRoutes[path]) + const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/) + if (currentSessionMatch) { + const session = config.sessions.find((item) => item.id === currentSessionMatch[1]) + if (!session) return json(route, { error: "Session not found" }, undefined, 404) + return json(route, { + data: currentSession(session, config.directory), + }) + } + const sessionMatch = path.match(/^\/session\/([^/]+)$/) if (sessionMatch) { const session = config.sessions.find((s) => s.id === sessionMatch[1]) return json(route, session ?? {}) } - if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, []) + const projectMatch = path.match(/^\/project\/([^/]+)$/) + if (projectMatch) return json(route, config.project) + + const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/) + if (messageMatch) { + config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! }) + if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) + const message = config.message?.(messageMatch[1]!, messageMatch[2]!) + if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404) + return json(route, message) + } + + const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/) + if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? []) + if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) + + const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/) + if (currentMessagesMatch) { + const token = url.searchParams.get("cursor") ?? undefined + const before = token ? cursors.get(token) : undefined + if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400) + config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" }) + await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before }) + if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) + const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before) + config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" }) + const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined + if (cursor) cursors.set(cursor, pageData.cursor!) + return json(route, { + data: pageData.items.map(currentMessage).reverse(), + cursor: { next: cursor }, + }) + } const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) if (messagesMatch) { + const token = url.searchParams.get("before") ?? undefined + const before = token ? cursors.get(token) : undefined + if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400) + config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" }) + await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before }) + if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) const limit = Number(url.searchParams.get("limit") ?? 80) - const before = url.searchParams.get("before") ?? undefined const pageData = config.pageMessages(messagesMatch[1], limit, before) - return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined) + config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" }) + if (!pageData.cursor) return json(route, pageData.items) + const cursor = `cursor_${++nextCursor}` + cursors.set(cursor, pageData.cursor) + return json(route, pageData.items, { "x-next-cursor": cursor }) } - return json(route, {}) + if (url.port === targetPort && targetPort !== appPort) return json(route, {}) + return route.fallback() }) } -function json(route: Route, body: unknown, headers?: Record) { +function location(config: MockServerConfig) { + return { + directory: config.directory, + project: { id: (config.project as { id?: string }).id, directory: config.directory }, + } +} + +function currentPermission(value: unknown) { + const permission = value as Record + if (permission.action) return permission + const tool = permission.tool as { messageID?: string; callID?: string } | undefined + return { + id: permission.id, + sessionID: permission.sessionID, + action: permission.permission, + resources: permission.patterns ?? [], + save: permission.always, + metadata: permission.metadata, + source: + tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined, + } +} + +export function currentSession(session: { id: string } & Record, fallbackDirectory?: string) { + const time = session.time && typeof session.time === "object" ? session.time : {} + return { + id: session.id, + parentID: session.parentID, + projectID: session.projectID ?? "project", + agent: session.agent ?? "build", + model: session.model ?? { id: "mock-model", providerID: "mock-provider" }, + cost: session.cost ?? 0, + tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { + created: "created" in time && typeof time.created === "number" ? time.created : 0, + updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0, + ...(session.time && typeof session.time === "object" && "archived" in session.time + ? { archived: session.time.archived } + : {}), + }, + title: session.title ?? session.id, + location: { + directory: typeof session.directory === "string" ? session.directory : fallbackDirectory, + ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}), + }, + subpath: session.path, + revert: session.revert, + } +} + +function currentMessage(value: unknown) { + const item = value as { + info: Record & { id: string; role: "user" | "assistant"; time: { created: number } } + parts: Array & { type: string }> + } + if (item.info.role === "user") { + return { + id: item.info.id, + type: "user", + time: item.info.time, + text: item.parts + .flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : [])) + .join("\n"), + } + } + return { + id: item.info.id, + type: "assistant", + time: item.info.time, + agent: item.info.agent ?? "build", + model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" }, + cost: item.info.cost, + tokens: item.info.tokens, + error: item.info.error, + content: item.parts.flatMap((part) => { + if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }] + if (part.type !== "tool") return [] + const state = part.state as Record + return [ + { + type: "tool", + id: part.id, + name: part.tool, + time: state.time ?? { created: item.info.time.created }, + state: + state.status === "pending" + ? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) } + : state.status === "completed" + ? { + status: "completed", + input: state.input ?? {}, + structured: state.metadata ?? {}, + content: [{ type: "text", text: state.output ?? "" }], + } + : state.status === "error" + ? { + status: "error", + input: state.input ?? {}, + structured: state.metadata ?? {}, + content: [], + error: { type: "ToolError", message: state.error ?? "Tool failed" }, + } + : { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] }, + }, + ] + }), + } +} + +function json(route: Route, body: unknown, headers?: Record, status = 200) { return route.fulfill({ - status: 200, + status, contentType: "application/json", headers: { "access-control-allow-origin": "*", @@ -83,10 +436,25 @@ function json(route: Route, body: unknown, headers?: Record) { }) } -function sse(route: Route, events?: unknown[]) { +function sse(route: Route, events?: unknown[], retry?: number) { return route.fulfill({ status: 200, contentType: "text/event-stream", - body: events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n", + body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`, }) } + +function currentEvent(input: unknown) { + if (!input || typeof input !== "object" || !("payload" in input)) return input + const envelope = input as { directory?: string; payload?: unknown } + if (!envelope.payload || typeof envelope.payload !== "object") return input + const payload = envelope.payload as { id?: string; type?: string; properties?: unknown } + if (!payload.type) return input + return { + id: payload.id ?? `evt_mock_${Date.now()}`, + created: Date.now(), + type: payload.type, + data: payload.properties ?? {}, + location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined, + } +} diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts new file mode 100644 index 000000000000..a0a20a2e17f2 --- /dev/null +++ b/packages/app/e2e/utils/sse-transport.ts @@ -0,0 +1,317 @@ +import type { Page } from "@playwright/test" + +export type SseConnectionRecord = { + id: number + url: string + path: "/global/event" | "/event" | "/api/event" + headers: Record + openedAt: number + endedAt?: number + endedBy?: "close" | "disconnect" | "error" | "abort" + error?: string +} + +export type SseDeliveryAcknowledgement = { + deliveryID: number + connectionID: number + bytes: number + chunkCount: number + deliveredAt: number + eventID?: string +} + +export type SseEventOptions = { + id?: string + event?: string + retry?: number + marker?: string +} + +export type SseTransport = { + server: string + waitForConnection(options?: { after?: number; timeout?: number }): Promise + send(payload: T, options?: SseEventOptions): Promise + burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise + split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise + heartbeat(options?: SseEventOptions): Promise + writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise + close(): Promise + disconnect(message?: string): Promise + error(message?: string): Promise + connections(): Promise + acknowledgements(): Promise +} + +type BrowserCommand = + | { type: "send"; deliveries: { payload: T; options?: SseEventOptions }[]; burst: boolean; cuts?: number[] } + | { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string } + | { type: "end"; mode: "close" | "disconnect" | "error"; message?: string } + | { type: "connections" } + | { type: "acknowledgements" } + +type BrowserTransport = Window & { + __testSseTransport?: { + command: (command: BrowserCommand) => unknown + } +} + +export async function installSseTransport( + page: Page, + options: { server: string; retry?: number }, +): Promise> { + const server = new URL(options.server).origin + await page.addInitScript( + ({ server, retry }) => { + type Connection = SseConnectionRecord & { controller: ReadableStreamDefaultController } + type ProbeWindow = Window & { + __visualStabilityProbe?: { startedAt: number; markers: { at: number; label: string }[] } + } + const originalFetch = window.fetch.bind(window) + const connections: Connection[] = [] + const acknowledgements: SseDeliveryAcknowledgement[] = [] + const encoder = new TextEncoder() + let nextConnectionID = 0 + let nextDeliveryID = 0 + + const current = () => connections.findLast((connection) => connection.endedAt === undefined) + const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => { + const boundaries = [...new Set(cuts ?? [])] + .filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength) + .sort((a, b) => a - b) + return [0, ...boundaries].map((start, index) => bytes.slice(start, boundaries[index] ?? bytes.byteLength)) + } + const marker = (label?: string) => { + if (!label) return + const probe = (window as ProbeWindow).__visualStabilityProbe + if (!probe) return + probe.markers.push({ at: performance.now() - probe.startedAt, label }) + } + const frame = (payload: unknown, eventOptions: SseEventOptions = {}) => + [ + eventOptions.event === undefined ? "" : `event: ${eventOptions.event}\n`, + eventOptions.id === undefined ? "" : `id: ${eventOptions.id}\n`, + eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`, + `data: ${JSON.stringify(payload)}\n\n`, + ].join("") + const currentEvent = (input: unknown) => { + if (!input || typeof input !== "object" || !("payload" in input)) return input + const envelope = input as { directory?: string; payload?: unknown } + if (!envelope.payload || typeof envelope.payload !== "object") return input + const payload = envelope.payload as { id?: string; type?: string; properties?: unknown } + if (!payload.type) return input + return { + id: payload.id ?? `evt_mock_${Date.now()}`, + created: Date.now(), + type: payload.type, + data: payload.properties ?? {}, + location: + envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined, + } + } + const acknowledge = ( + connection: Connection, + bytes: number, + chunkCount: number, + eventID?: string, + ): SseDeliveryAcknowledgement => { + const acknowledgement = { + deliveryID: ++nextDeliveryID, + connectionID: connection.id, + bytes, + chunkCount, + deliveredAt: performance.now(), + ...(eventID === undefined ? {} : { eventID }), + } + acknowledgements.push(acknowledgement) + return acknowledgement + } + const end = (mode: "close" | "disconnect" | "error", message?: string) => { + const connection = current() + if (!connection) throw new Error("SSE transport has no active connection") + connection.endedAt = performance.now() + connection.endedBy = mode + if (message) connection.error = message + if (mode === "close") { + connection.controller.close() + return + } + const error = new DOMException( + message ?? "SSE connection disconnected", + mode === "error" ? "Error" : "NetworkError", + ) + connection.controller.error(error) + } + + const command = (input: BrowserCommand) => { + if (input.type === "connections") + return connections.map(({ controller: _controller, ...connection }) => connection) + if (input.type === "acknowledgements") return acknowledgements + if (input.type === "end") return end(input.mode, input.message) + const connection = current() + if (!connection) throw new Error("SSE transport has no active connection") + if (input.type === "raw") { + marker(input.marker) + const output = chunks(new Uint8Array(input.bytes), input.cuts) + output.forEach((chunk) => connection.controller.enqueue(chunk)) + return acknowledge(connection, input.bytes.length, output.length) + } + const encoded = input.deliveries.map((delivery) => { + const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload + return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) } + }) + encoded.forEach((item) => marker(item.delivery.options?.marker)) + if (input.burst) { + const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join("")) + connection.controller.enqueue(bytes) + return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id)) + } + const output = chunks(encoded[0]!.bytes, input.cuts) + output.forEach((chunk) => connection.controller.enqueue(chunk)) + return acknowledge(connection, encoded[0]!.bytes.byteLength, output.length, encoded[0]!.delivery.options?.id) + } + + ;(window as BrowserTransport).__testSseTransport = { command } + const fetch = (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + const url = new URL(request.url) + if ( + url.origin !== server || + (url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event") + ) + return originalFetch(request) + + const id = ++nextConnectionID + const record = { + id, + url: url.href, + path: url.pathname, + headers: Object.fromEntries(request.headers.entries()), + openedAt: performance.now(), + } as Connection + const stream = new ReadableStream({ + start(controller) { + record.controller = controller + connections.push(record) + if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`)) + if (url.pathname === "/api/event") + controller.enqueue( + encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })), + ) + if (url.pathname === "/global/event") + controller.enqueue( + encoder.encode( + frame({ + payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} }, + }), + ), + ) + request.signal.addEventListener( + "abort", + () => { + if (record.endedAt !== undefined) return + record.endedAt = performance.now() + record.endedBy = "abort" + controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError")) + }, + { once: true }, + ) + }, + cancel() { + if (record.endedAt !== undefined) return + record.endedAt = performance.now() + record.endedBy = "disconnect" + }, + }) + return Promise.resolve( + new Response(stream, { + status: 200, + headers: { + "cache-control": "no-cache", + "content-type": "text/event-stream", + }, + }), + ) + } + Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch }) + }, + { server, retry: options.retry }, + ) + + const command = (input: BrowserCommand) => + page.evaluate((input) => { + const transport = (window as BrowserTransport).__testSseTransport + if (!transport) throw new Error("SSE transport was not installed before page load") + return transport.command(input as BrowserCommand) + }, input) as Promise + + return { + server, + async waitForConnection(input = {}) { + const connection = await page.waitForFunction( + (after) => { + const transport = (window as BrowserTransport).__testSseTransport + const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined + return connections?.findLast((connection) => connection.id > after && connection.endedAt === undefined) + }, + input.after ?? 0, + { timeout: input.timeout }, + ) + let result: SseConnectionRecord | undefined + try { + result = await connection.jsonValue() + } finally { + await connection.dispose() + } + if (!result) throw new Error("SSE transport connection disappeared while waiting") + return result + }, + send(payload, eventOptions) { + return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false }) + }, + burst(payloads, eventOptions = []) { + return command({ + type: "send", + deliveries: payloads.map((payload, index) => ({ payload, options: eventOptions[index] })), + burst: true, + }) + }, + split(payload, cuts, eventOptions) { + return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, cuts: [...cuts] }) + }, + heartbeat(eventOptions) { + return command({ + type: "send", + deliveries: [ + { + payload: { directory: "global", payload: { type: "server.heartbeat", properties: {} } } as T, + options: eventOptions, + }, + ], + burst: false, + }) + }, + writeRaw(value, cuts, marker) { + return command({ + type: "raw", + bytes: Array.from(typeof value === "string" ? new TextEncoder().encode(value) : value), + cuts: cuts ? [...cuts] : undefined, + marker, + }) + }, + close() { + return command({ type: "end", mode: "close" }) + }, + disconnect(message) { + return command({ type: "end", mode: "disconnect", message }) + }, + error(message) { + return command({ type: "end", mode: "error", message }) + }, + connections() { + return command({ type: "connections" }) + }, + acknowledgements() { + return command({ type: "acknowledgements" }) + }, + } +} diff --git a/packages/app/e2e/utils/visual-stability.ts b/packages/app/e2e/utils/visual-stability.ts new file mode 100644 index 000000000000..2c56864ed8dd --- /dev/null +++ b/packages/app/e2e/utils/visual-stability.ts @@ -0,0 +1,54 @@ +import type { Page, TestInfo } from "@playwright/test" +import { analyzeVisualObservations, analyzeVisualTraceByMarker } from "./visual-stability/analyzer" +import { legacyVisualPlan, type LegacyVisualStabilityOptions } from "./visual-stability/invariant" +import type { CapturedFrame, VisualStabilityTrace } from "./visual-stability/model" +import { markVisualProbe, startVisualProbe, stopVisualProbe } from "./visual-stability/probe" +import type { VisualRegionDefinition } from "./visual-stability/regions" +import { reportVisualStability } from "./visual-stability/reporter" + +export * from "./visual-stability/index" + +const capturedFrames = Symbol("capturedFrames") + +export async function startVisualStabilityProbe(page: Page, regions: Record) { + await startVisualProbe(page, regions) +} + +export async function stopVisualStabilityProbe(page: Page) { + const result = await stopVisualProbe(page) + const trace: VisualStabilityTrace = { markers: result.markers, samples: result.samples } + Object.defineProperty(trace, capturedFrames, { value: result.frames }) + return trace +} + +export async function markVisualStability(page: Page, label: string) { + await markVisualProbe(page, label) +} + +export function analyzeVisualStability(trace: VisualStabilityTrace, options: LegacyVisualStabilityOptions = {}) { + return analyzeVisualObservations(trace.samples, legacyVisualPlan(options)) +} + +export function analyzeVisualStabilityByMarker( + trace: VisualStabilityTrace, + options: LegacyVisualStabilityOptions = {}, +) { + return analyzeVisualTraceByMarker(trace, legacyVisualPlan(options)) +} + +export async function expectVisualStability( + testInfo: TestInfo, + name: string, + trace: VisualStabilityTrace, + options: LegacyVisualStabilityOptions = {}, +) { + await reportVisualStability( + testInfo, + name, + { + ...trace, + frames: (trace as VisualStabilityTrace & { [capturedFrames]?: CapturedFrame[] })[capturedFrames] ?? [], + }, + legacyVisualPlan(options), + ) +} diff --git a/packages/app/e2e/utils/visual-stability/analyzer.ts b/packages/app/e2e/utils/visual-stability/analyzer.ts new file mode 100644 index 000000000000..902d222e1da8 --- /dev/null +++ b/packages/app/e2e/utils/visual-stability/analyzer.ts @@ -0,0 +1,209 @@ +import type { VisualInvariant, VisualPlan } from "./invariant" +import type { VisualObservation, VisualStabilityTrace } from "./model" + +export function analyzeVisualObservations( + observations: readonly VisualObservation[], + plan: VisualPlan, +) { + const issues: string[] = [] + const invariants = plan.invariants + const names = [...new Set(observations.flatMap((sample) => Object.keys(sample.regions) as RegionName[]))] + const required = regions(invariants, "required") + const continuousAny = invariants.filter( + (invariant): invariant is Extract, { type: "continuous-any" }> => + invariant.type === "continuous-any", + ) + const unique = new Set(regions(invariants, "unique")) + const stable = new Set(regions(invariants, "stable")) + const fixed = invariants.filter( + (invariant): invariant is Extract, { type: "fixed" }> => invariant.type === "fixed", + ) + const opacity = invariants.filter( + (invariant): invariant is Extract, { type: "opacity" }> => invariant.type === "opacity", + ) + const continuity = invariants.filter( + (invariant): invariant is Extract, { type: "continuity" }> => + invariant.type === "continuity", + ) + const motion = invariants.filter( + (invariant): invariant is Extract, { type: "motion" }> => invariant.type === "motion", + ) + const labelStability = invariants.filter( + (invariant): invariant is Extract, { type: "label-stability" }> => + invariant.type === "label-stability", + ) + + for (const name of new Set(required)) { + if (!observations.some((sample) => sample.regions[name]?.visible)) issues.push(`${name} never rendered`) + } + for (const invariant of continuousAny) { + if (!invariant.regions.some((name) => observations.some((sample) => sample.regions[name]?.visible))) + issues.push(`${invariant.regions.join(" | ")} never rendered`) + } + + for (const name of names) { + const samples = observations.flatMap((observation) => { + const region = observation.regions[name] + if (!region) return [] + const clipped = + observation.viewport && (region.bottom <= observation.viewport.top || region.top >= observation.viewport.bottom) + return [{ at: observation.at, ...region, visible: region.visible && !clipped }] + }) + const visible = samples.filter((sample) => sample.visible) + if (visible.length === 0) continue + if (unique.has(name)) { + const duplicate = samples.find((sample) => sample.count > 1) + if (duplicate) issues.push(`${name} appeared ${duplicate.count} times at ${Math.round(duplicate.at)}ms`) + } + if (stable.has(name)) { + const identities = [...new Set(visible.map((sample) => sample.node).filter((node) => node > 0))] + if (identities.length > 1) issues.push(`${name} remounted ${identities.length - 1} times`) + } + for (const invariant of fixed.filter((invariant) => includes(invariant.regions, name))) { + const origin = visible[0] + const movement = origin ? Math.max(0, ...visible.map((sample) => Math.abs(sample.top - origin.top))) : 0 + if (movement > (invariant.tolerance ?? 1)) + issues.push(`${name} moved ${Math.round(movement * 10) / 10}px in the viewport`) + } + for (const invariant of opacity.filter((invariant) => includes(invariant.regions, name))) { + for (const sample of visible) { + if (sample.opacity < (invariant.floor ?? 0.65)) + issues.push(`${name} opacity fell to ${sample.opacity} at ${Math.round(sample.at)}ms`) + } + } + if (continuity.some((invariant) => includes(invariant.regions, name))) { + const firstPresent = samples.findIndex((sample) => sample.present) + const lastPresent = samples.findLastIndex((sample) => sample.present) + if (samples.slice(firstPresent, lastPresent + 1).some((sample) => !sample.present)) + issues.push(`${name} disappeared between present frames`) + const firstVisible = samples.findIndex((sample) => sample.visible) + const lastVisible = samples.findLastIndex((sample) => sample.visible) + if ( + firstVisible >= 0 && + samples.slice(firstVisible, lastVisible + 1).some((sample) => !sample.visible && sample.inViewport) + ) + issues.push(`${name} blanked between visible frames`) + } + for (const invariant of motion.filter((invariant) => includes(invariant.regions, name))) { + for (const metric of ["top", "bottom", "width", "height"] as const) { + const directions = visible + .slice(1) + .map((sample, index) => sample[metric] - visible[index]![metric]) + .filter((delta) => Math.abs(delta) > (invariant.tolerance ?? 1)) + .map(Math.sign) + const reversals = directions.slice(1).filter((direction, index) => direction !== directions[index]).length + const allowed = + metric === "top" || metric === "bottom" + ? (invariant.maxPositionReversals ?? invariant.maxReversals ?? 1) + : (invariant.maxReversals ?? 1) + if (reversals > allowed) issues.push(`${name} ${metric} reversed ${reversals} times`) + } + } + if (labelStability.some((invariant) => includes(invariant.regions, name))) { + const labels = samples + .map((sample) => sample.label) + .filter((label) => label.length > 0) + .filter((label, index, all) => label !== all[index - 1]) + if (labels.some((label, index) => labels.indexOf(label) !== index)) + issues.push(`${name} label reverted: ${labels.join(" -> ")}`) + } + } + + if (invariants.some((invariant) => invariant.type === "preserve-bottom-anchor")) { + const viewports = observations.flatMap((sample) => (sample.viewport ? [sample.viewport] : [])) + if (viewports[0] && viewports[0].distanceFromBottom <= 4) { + const lost = viewports.find((viewport) => viewport.distanceFromBottom > 4) + if (lost) issues.push(`bottom anchor moved to ${lost.distanceFromBottom}px`) + } + } + if (invariants.some((invariant) => invariant.type === "acquire-bottom-anchor")) { + const final = observations.findLast((sample) => sample.viewport)?.viewport + if (!final || final.distanceFromBottom > 4) + issues.push(`did not acquire bottom anchor${final ? ` (${final.distanceFromBottom}px away)` : ""}`) + } + + for (const invariant of continuousAny) { + const active = observations.map((sample) => invariant.regions.some((name) => sample.regions[name]?.visible)) + const first = active.indexOf(true) + const last = active.lastIndexOf(true) + if (first >= 0 && active.slice(first, last + 1).some((value) => !value)) + issues.push(`${invariant.regions.join(" | ")} blanked between visible frames`) + } + + for (const invariant of invariants.filter( + (item): item is Extract, { type: "flow" }> => item.type === "flow", + )) { + for (const [before, after] of invariant.regions + .slice(1) + .map((after, index) => [invariant.regions[index]!, after])) { + let maximum: { overlap: number; at: number } | undefined + let inverted: { at: number } | undefined + for (const sample of observations) { + const first = sample.regions[before] + const second = sample.regions[after] + if (!first?.visible || !second?.visible) continue + if ( + sample.viewport && + (first.bottom <= sample.viewport.top || + first.top >= sample.viewport.bottom || + second.bottom <= sample.viewport.top || + second.top >= sample.viewport.bottom) + ) + continue + const overlap = first.bottom - second.top + if (first.top > second.top && !inverted) inverted = { at: sample.at } + if (overlap > (invariant.overlapTolerance ?? 0.5) && (!maximum || overlap > maximum.overlap)) + maximum = { overlap, at: sample.at } + } + if (inverted) issues.push(`${before} rendered after ${after} at ${Math.round(inverted.at)}ms`) + if (maximum) + issues.push( + `${before} overlapped ${after} by ${Math.round(maximum.overlap * 10) / 10}px at ${Math.round(maximum.at)}ms`, + ) + } + } + return [...new Set(issues)] +} + +export function analyzeVisualTraceByMarker( + trace: VisualStabilityTrace, + plan: VisualPlan, +) { + if (trace.markers.length === 0) return analyzeVisualObservations(trace.samples, plan) + const required = [...new Set(plan.markerRequired ?? regions(plan.invariants, "required"))].flatMap((name) => + trace.samples.some((sample) => sample.regions[name]?.visible) ? [] : [`${name} never rendered`], + ) + const withoutRequired = plan.invariants.filter((invariant) => invariant.type !== "required") + const windows = trace.markers.flatMap((marker, index) => { + const end = trace.markers[index + 1]?.at ?? Infinity + const before = trace.samples.findLast((sample) => sample.at < marker.at) + const samples = [ + ...(before ? [before] : []), + ...trace.samples.filter((sample) => sample.at >= marker.at && sample.at < end), + ] + if (samples.length < 2) return [] + return analyzeVisualObservations(samples, { ...plan, perMarker: false, invariants: withoutRequired }).map( + (issue) => `${marker.label}: ${issue}`, + ) + }) + const aggregateMotion = + plan.aggregateMotion === false + ? [] + : analyzeVisualObservations(trace.samples, { + invariants: plan.invariants.filter((invariant) => invariant.type === "motion"), + }).filter((issue) => / (?:top|bottom|width|height) reversed \d+ times$/.test(issue)) + return [...new Set([...required, ...aggregateMotion, ...windows])] +} + +function regions["type"]>( + invariants: readonly VisualInvariant[], + type: Type, +) { + return invariants.flatMap((invariant) => + invariant.type === type && "regions" in invariant && invariant.regions !== "all" ? [...invariant.regions] : [], + ) as RegionName[] +} + +function includes(regions: readonly RegionName[] | "all", name: RegionName) { + return regions === "all" || regions.includes(name) +} diff --git a/packages/app/e2e/utils/visual-stability/capture.ts b/packages/app/e2e/utils/visual-stability/capture.ts new file mode 100644 index 000000000000..3bb583a9d5a4 --- /dev/null +++ b/packages/app/e2e/utils/visual-stability/capture.ts @@ -0,0 +1,51 @@ +import type { CDPSession, Page } from "@playwright/test" +import type { CapturedFrame } from "./model" + +export type VisualCapture = { + session: CDPSession + frames: CapturedFrame[] + startedAtEpoch: number + running: boolean + capture: Promise +} + +export async function startVisualCapture(page: Page, startedAtEpoch: number) { + if (process.env.OPENCODE_STABILITY_CAPTURE !== "1") return + const session = await page.context().newCDPSession(page) + await session.send("Page.enable") + const recording: VisualCapture = { + session, + frames: [], + startedAtEpoch, + running: true, + capture: Promise.resolve(), + } + recording.capture = (async () => { + try { + while (recording.running && recording.frames.length < 900) { + const frame = await session.send("Page.captureScreenshot", { + format: "jpeg", + quality: 80, + captureBeyondViewport: false, + optimizeForSpeed: true, + }) + recording.frames.push({ at: Date.now() - recording.startedAtEpoch, data: frame.data }) + await new Promise((resolve) => setTimeout(resolve, 50)) + } + } catch { + recording.running = false + } + })() + return recording +} + +export async function stopVisualCapture(recording: VisualCapture | undefined) { + if (!recording) return [] + recording.running = false + try { + await recording.capture + } finally { + await recording.session.detach().catch(() => undefined) + } + return recording.frames +} diff --git a/packages/app/e2e/utils/visual-stability/index.ts b/packages/app/e2e/utils/visual-stability/index.ts new file mode 100644 index 000000000000..6fdbf4acfc60 --- /dev/null +++ b/packages/app/e2e/utils/visual-stability/index.ts @@ -0,0 +1,8 @@ +export * from "./analyzer" +export * from "./capture" +export * from "./invariant" +export * from "./model" +export * from "./probe" +export * from "./regions" +export * from "./reporter" +export * from "./scenario" diff --git a/packages/app/e2e/utils/visual-stability/invariant.ts b/packages/app/e2e/utils/visual-stability/invariant.ts new file mode 100644 index 000000000000..17f6ef191361 --- /dev/null +++ b/packages/app/e2e/utils/visual-stability/invariant.ts @@ -0,0 +1,112 @@ +import type { VisualRegionDefinition } from "./regions" + +type RegionSet = readonly RegionName[] | "all" + +export type VisualInvariant = + | { type: "required"; regions: readonly RegionName[] } + | { type: "continuous-any"; regions: readonly RegionName[] } + | { type: "unique"; regions: readonly RegionName[] } + | { type: "stable"; regions: readonly RegionName[] } + | { type: "fixed"; regions: readonly RegionName[]; tolerance?: number } + | { type: "opacity"; regions: RegionSet; floor?: number } + | { + type: "motion" + regions: RegionSet + tolerance?: number + maxReversals?: number + maxPositionReversals?: number + } + | { type: "continuity"; regions: RegionSet } + | { type: "label-stability"; regions: RegionSet } + | { type: "flow"; regions: readonly RegionName[]; overlapTolerance?: number } + | { type: "preserve-bottom-anchor" } + | { type: "acquire-bottom-anchor" } + +export type VisualPlan = { + regionNames?: readonly RegionName[] + invariants: readonly VisualInvariant[] + markerRequired?: readonly RegionName[] + perMarker?: boolean + aggregateMotion?: boolean +} + +export type LegacyVisualStabilityOptions = { + flow?: RegionName[] + motionTolerance?: number + opacityFloor?: number + overlapTolerance?: number + maxReversals?: number + maxPositionReversals?: number + stable?: RegionName[] + fixed?: RegionName[] + motion?: RegionName[] + unique?: RegionName[] + preserveBottomAnchor?: boolean + acquireBottomAnchor?: boolean + perMarker?: boolean + continuousAny?: RegionName[][] + required?: RegionName[] + aggregateMotion?: boolean + inferRequired?: boolean +} + +export function visualPlan>( + regions: Regions, + invariants: readonly VisualInvariant>[], + options: Omit>, "regionNames" | "invariants"> = {}, +): VisualPlan> { + return { ...options, regionNames: Object.keys(regions) as Extract[], invariants } +} + +export function legacyVisualPlan( + options: LegacyVisualStabilityOptions = {}, +): VisualPlan { + const inferred = + options.inferRequired === false + ? [] + : [ + ...(options.stable ?? []), + ...(options.fixed ?? []), + ...(options.unique ?? []), + ...(options.motion ?? []), + ...(options.flow ?? []), + ] + return { + perMarker: options.perMarker, + aggregateMotion: options.aggregateMotion, + markerRequired: [ + ...(options.required ?? []), + ...(options.stable ?? []), + ...(options.fixed ?? []), + ...(options.unique ?? []), + ...(options.motion ?? []), + ...(options.flow ?? []), + ], + invariants: [ + { type: "required", regions: [...(options.required ?? []), ...inferred] }, + ...(options.continuousAny ?? []).map( + (regions): VisualInvariant => ({ type: "continuous-any", regions }), + ), + ...(options.unique ? [{ type: "unique" as const, regions: options.unique }] : []), + ...(options.stable ? [{ type: "stable" as const, regions: options.stable }] : []), + ...(options.fixed + ? [{ type: "fixed" as const, regions: options.fixed, tolerance: options.motionTolerance }] + : []), + { type: "opacity", regions: "all", floor: options.opacityFloor }, + { type: "continuity", regions: "all" }, + { + type: "motion", + regions: options.motion ?? "all", + tolerance: options.motionTolerance, + maxReversals: options.maxReversals, + maxPositionReversals: options.maxPositionReversals, + }, + { type: "label-stability", regions: "all" }, + ...(options.preserveBottomAnchor ? [{ type: "preserve-bottom-anchor" as const }] : []), + ...(options.acquireBottomAnchor ? [{ type: "acquire-bottom-anchor" as const }] : []), + ...(options.flow + ? [{ type: "flow" as const, regions: options.flow, overlapTolerance: options.overlapTolerance }] + : []), + ], + } +} diff --git a/packages/app/e2e/utils/visual-stability/model.ts b/packages/app/e2e/utils/visual-stability/model.ts new file mode 100644 index 000000000000..1b966638c9ce --- /dev/null +++ b/packages/app/e2e/utils/visual-stability/model.ts @@ -0,0 +1,47 @@ +export type VisualRegionSample = { + present: boolean + visible: boolean + inViewport: boolean + cssHidden?: boolean + top: number + bottom: number + layoutTop?: number + layoutBottom?: number + width: number + height: number + opacity: number + count: number + node: number + label: string + text: string +} + +export type VisualViewportSample = { + top: number + bottom: number + scrollTop: number + scrollHeight: number + clientHeight: number + distanceFromBottom: number +} + +export type VisualObservation = { + at: number + regions: string extends RegionName + ? Record + : Partial> + viewport?: VisualViewportSample +} + +export type VisualMarker = { at: number; label: string } + +export type VisualStabilityTrace = { + markers: VisualMarker[] + samples: VisualObservation[] +} + +export type CapturedFrame = { at: number; data: string } + +export type VisualProbeResult = VisualStabilityTrace & { + frames: CapturedFrame[] +} diff --git a/packages/app/e2e/utils/visual-stability/probe.ts b/packages/app/e2e/utils/visual-stability/probe.ts new file mode 100644 index 000000000000..abf274d3592a --- /dev/null +++ b/packages/app/e2e/utils/visual-stability/probe.ts @@ -0,0 +1,226 @@ +import type { Page } from "@playwright/test" +import { startVisualCapture, stopVisualCapture, type VisualCapture } from "./capture" +import type { VisualMarker, VisualObservation, VisualProbeResult } from "./model" +import type { VisualRegionDefinition } from "./regions" + +type ProbeWindow = Window & { + __visualStabilityProbe?: { + startedAt: number + markers: VisualMarker[] + samples: VisualObservation[] + stop: () => void + } +} + +const captures = new WeakMap() + +export async function startVisualProbe>( + page: Page, + regions: Regions, +) { + await stopCapture(page) + await page.evaluate(() => { + ;(window as ProbeWindow).__visualStabilityProbe?.stop() + }) + const startedAtEpoch = await page.evaluate((regions) => { + const samples: VisualObservation[] = [] + const markers: VisualMarker[] = [] + const startedAt = performance.now() + const nodes = new WeakMap() + const lastBounds = new Map() + let nextNode = 1 + let running = true + const round = (value: number) => Math.round(value * 10) / 10 + const opacity = (element: Element) => Number(getComputedStyle(element).opacity) + const sample = () => { + if (!running) return + setTimeout(() => { + if (!running) return + const viewport = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => + element.querySelector("[data-timeline-row]"), + ) + const viewportRect = viewport?.getBoundingClientRect() + samples.push({ + at: performance.now() - startedAt, + viewport: viewport + ? { + top: round(viewportRect!.top), + bottom: round(viewportRect!.bottom), + scrollTop: round(viewport.scrollTop), + scrollHeight: round(viewport.scrollHeight), + clientHeight: round(viewport.clientHeight), + distanceFromBottom: round(viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop), + } + : undefined, + regions: Object.fromEntries( + Object.entries(regions).map(([name, config]) => { + const found = document.querySelector(config.selector) + const count = document.querySelectorAll(config.selector).length + const element = config.closest ? found?.closest(config.closest) : found + if (!element) + return [ + name, + { + present: false, + visible: false, + inViewport: false, + top: 0, + bottom: 0, + width: 0, + height: 0, + opacity: 0, + count, + node: 0, + label: "", + text: "", + }, + ] + const rect = element.getBoundingClientRect() + const style = getComputedStyle(element) + if (rect.height > 0) lastBounds.set(name, { top: rect.top, bottom: rect.bottom }) + const known = rect.height > 0 ? rect : lastBounds.get(name) + const painted = (() => { + const result = { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right } + let parent = element.parentElement + while (parent) { + const parentStyle = getComputedStyle(parent) + if (["hidden", "clip", "scroll", "auto"].includes(parentStyle.overflowY)) { + const parentRect = parent.getBoundingClientRect() + result.top = Math.max(result.top, parentRect.top) + result.bottom = Math.min(result.bottom, parentRect.bottom) + } + if (["hidden", "clip", "scroll", "auto"].includes(parentStyle.overflowX)) { + const parentRect = parent.getBoundingClientRect() + result.left = Math.max(result.left, parentRect.left) + result.right = Math.min(result.right, parentRect.right) + } + if (parent === viewport) break + parent = parent.parentElement + } + if (viewportRect) { + result.top = Math.max(result.top, viewportRect.top) + result.bottom = Math.min(result.bottom, viewportRect.bottom) + result.left = Math.max(result.left, viewportRect.left) + result.right = Math.min(result.right, viewportRect.right) + } + return result + })() + const contentOpacity = config.opacitySelectors?.length + ? Math.max( + 0, + ...config.opacitySelectors.flatMap((selector) => + [...element.querySelectorAll(selector)].map((node) => { + let value = 1 + let current: Element | null = node + while (current) { + value *= opacity(current) + if (current === element) break + current = current.parentElement + } + return value + }), + ), + ) + : opacity(element) + let visibleOpacity = contentOpacity + let ancestor = element.parentElement + let ancestorHidden = false + while (ancestor) { + const ancestorStyle = getComputedStyle(ancestor) + visibleOpacity *= Number(ancestorStyle.opacity) + if (ancestorStyle.display === "none" || ancestorStyle.visibility === "hidden") ancestorHidden = true + if (ancestor === viewport) break + ancestor = ancestor.parentElement + } + const cssHidden = + ancestorHidden || style.display === "none" || style.visibility === "hidden" || visibleOpacity === 0 + return [ + name, + { + present: true, + visible: + style.display !== "none" && + style.visibility !== "hidden" && + visibleOpacity > 0 && + painted.right > painted.left && + painted.bottom > painted.top, + inViewport: + !viewportRect || (!!known && known.bottom > viewportRect.top && known.top < viewportRect.bottom), + cssHidden, + top: round(painted.top), + bottom: round(painted.bottom), + layoutTop: round(rect.top), + layoutBottom: round(rect.bottom), + width: round(painted.right - painted.left), + height: round(painted.bottom - painted.top), + opacity: round(visibleOpacity), + count, + node: (() => { + const current = nodes.get(element) + if (current) return current + nodes.set(element, nextNode) + return nextNode++ + })(), + label: element.getAttribute("aria-label") ?? "", + text: (element.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 500), + }, + ] + }), + ), + }) + requestAnimationFrame(sample) + }, 0) + } + ;(window as ProbeWindow).__visualStabilityProbe = { + startedAt, + markers, + samples, + stop: () => { + running = false + }, + } + requestAnimationFrame(sample) + return new Promise((resolve) => { + const ready = () => { + if (samples.length > 0) return resolve(performance.timeOrigin + startedAt) + requestAnimationFrame(ready) + } + ready() + }) + }, regions) + const capture = await startVisualCapture(page, startedAtEpoch) + if (capture) captures.set(page, capture) +} + +export async function stopVisualProbe( + page: Page, +): Promise> { + return page + .evaluate(() => { + const probe = (window as ProbeWindow).__visualStabilityProbe + if (!probe) throw new Error("Visual stability probe is not running") + probe.stop() + return { markers: probe.markers, samples: probe.samples } + }) + .then( + async (trace) => ({ ...trace, frames: await stopCapture(page) }) as unknown as VisualProbeResult, + async (error: unknown) => { + await stopCapture(page) + throw error + }, + ) +} + +export async function markVisualProbe(page: Page, label: string) { + await page.evaluate((label) => { + const probe = (window as ProbeWindow).__visualStabilityProbe + if (!probe) return + probe.markers.push({ at: performance.now() - probe.startedAt, label }) + }, label) +} + +async function stopCapture(page: Page) { + const capture = captures.get(page) + if (capture) captures.delete(page) + return stopVisualCapture(capture) +} diff --git a/packages/app/e2e/utils/visual-stability/regions.ts b/packages/app/e2e/utils/visual-stability/regions.ts new file mode 100644 index 000000000000..4c0b34e78a2a --- /dev/null +++ b/packages/app/e2e/utils/visual-stability/regions.ts @@ -0,0 +1,18 @@ +export type VisualRegionDefinition = { + selector: string + closest?: string + opacitySelectors?: readonly string[] +} + +export function defineVisualRegions>(regions: Regions) { + return regions +} + +export function mapVisualRegions, Result>( + regions: Regions, + map: (region: Regions[keyof Regions], name: keyof Regions) => Result, +) { + return Object.fromEntries( + Object.entries(regions).map(([name, region]) => [name, map(region as Regions[keyof Regions], name)]), + ) as { [Name in keyof Regions]: Result } +} diff --git a/packages/app/e2e/utils/visual-stability/reporter.ts b/packages/app/e2e/utils/visual-stability/reporter.ts new file mode 100644 index 000000000000..db283072a846 --- /dev/null +++ b/packages/app/e2e/utils/visual-stability/reporter.ts @@ -0,0 +1,63 @@ +import { expect, type TestInfo } from "@playwright/test" +import { writeFile } from "node:fs/promises" +import { analyzeVisualObservations, analyzeVisualTraceByMarker } from "./analyzer" +import type { VisualPlan } from "./invariant" +import type { VisualProbeResult } from "./model" + +export async function reportVisualStability( + testInfo: TestInfo, + name: string, + result: VisualProbeResult, + plan: VisualPlan, +) { + const trace = { markers: result.markers, samples: result.samples } + const issues = plan.perMarker + ? analyzeVisualTraceByMarker(trace, plan) + : analyzeVisualObservations(result.samples, plan) + const tracePath = testInfo.outputPath(`${name}-visual-trace.json`) + const issuesPath = testInfo.outputPath(`${name}-visual-issues.json`) + await writeFile(tracePath, JSON.stringify(trace, null, 2)) + await writeFile( + issuesPath, + JSON.stringify({ issues, markers: result.markers, capturedFrameCount: result.frames.length }, null, 2), + ) + await testInfo.attach(`${name}-visual-trace`, { path: tracePath, contentType: "application/json" }) + await testInfo.attach(`${name}-visual-issues`, { path: issuesPath, contentType: "application/json" }) + if (issues.length) await attachViolationFrames(testInfo, name, result, issues) + expect(issues, `${name}: ${issues.join("\n")}`).toEqual([]) +} + +async function attachViolationFrames( + testInfo: TestInfo, + name: string, + result: VisualProbeResult, + issues: string[], +) { + if (result.frames.length === 0) return + const targets = [ + ...new Set( + issues.flatMap((issue) => { + const match = issue.match(/ at (\d+)ms/) + if (match) return [Number(match[1])] + const marker = result.markers.find((item) => issue.startsWith(`${item.label}:`)) + return marker ? [marker.at] : [] + }), + ), + ].slice(0, 6) + for (const [violation, target] of targets.entries()) { + const nearest = result.frames.reduce( + (best, frame, index) => (Math.abs(frame.at - target) < Math.abs(result.frames[best]!.at - target) ? index : best), + 0, + ) + for (const [label, index] of [ + ["before", Math.max(0, nearest - 1)], + ["violation", nearest], + ["after", Math.min(result.frames.length - 1, nearest + 1)], + ] as const) { + await testInfo.attach(`${name}-${violation + 1}-${label}-${Math.round(result.frames[index]!.at)}ms`, { + body: Buffer.from(result.frames[index]!.data, "base64"), + contentType: "image/jpeg", + }) + } + } +} diff --git a/packages/app/e2e/utils/visual-stability/scenario.ts b/packages/app/e2e/utils/visual-stability/scenario.ts new file mode 100644 index 000000000000..213630207144 --- /dev/null +++ b/packages/app/e2e/utils/visual-stability/scenario.ts @@ -0,0 +1,20 @@ +import type { Page, TestInfo } from "@playwright/test" +import type { VisualPlan } from "./invariant" +import { startVisualProbe, stopVisualProbe } from "./probe" +import type { VisualRegionDefinition } from "./regions" +import { reportVisualStability } from "./reporter" + +export async function runVisualStabilityScenario>(input: { + page: Page + testInfo: TestInfo + name: string + regions: Regions + plan: VisualPlan> + run: () => Promise +}) { + await startVisualProbe(input.page, input.regions) + await input.run() + const result = await stopVisualProbe>(input.page) + await reportVisualStability(input.testInfo, input.name, result, input.plan) + return result +} diff --git a/packages/app/index.html b/packages/app/index.html index 8c86360af3de..d71535182c81 100644 --- a/packages/app/index.html +++ b/packages/app/index.html @@ -1,22 +1,28 @@ - + - + OpenCode - + + + + - + -
+
diff --git a/packages/app/package.json b/packages/app/package.json index 0336d7cd49fb..f31f65eba6b2 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,11 +1,12 @@ { "name": "@opencode-ai/app", - "version": "1.16.2", + "version": "1.18.18", "description": "", "type": "module", "exports": { ".": "./src/index.ts", "./desktop-menu": "./src/desktop-menu.ts", + "./i18n/desktop-native": "./src/i18n/desktop-native.ts", "./updater": "./src/updater.ts", "./wsl/types": "./src/wsl/types.ts", "./vite": "./vite.js", @@ -13,18 +14,21 @@ }, "scripts": { "typecheck": "tsgo -b", + "typecheck:e2e": "tsgo -p e2e/tsconfig.json", "start": "vite", "dev": "vite", "build": "vite build", "serve": "vite preview", - "test": "bun run test:unit", - "test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", - "test:unit": "bun test --preload ./happydom.ts ./src", - "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", + "test": "bun run test:unit && bun run test:browser", + "test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src", + "test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser", + "test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src", "test:e2e": "playwright test", "test:e2e:local": "playwright test", "test:e2e:ui": "playwright test --ui", - "test:e2e:report": "playwright show-report e2e/playwright-report" + "test:e2e:report": "playwright show-report e2e/playwright-report", + "test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts", + "test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts" }, "license": "MIT", "devDependencies": { @@ -37,16 +41,26 @@ "@types/luxon": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", + "tw-animate-css": "1.4.0", "typescript": "catalog:", "vite": "catalog:", "vite-plugin-icons-spritesheet": "3.0.1", "vite-plugin-solid": "catalog:" }, "dependencies": { + "@corvu/drawer": "catalog:", + "@dnd-kit/abstract": "0.5.0", + "@dnd-kit/dom": "0.5.0", + "@dnd-kit/helpers": "0.5.0", + "@dnd-kit/solid": "0.5.0", "@kobalte/core": "catalog:", + "@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13-v2.tgz", "@opencode-ai/core": "workspace:*", + "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/session-ui": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@pierre/trees": "1.0.0-beta.4", "@sentry/solid": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/active-element": "2.1.3", @@ -64,11 +78,12 @@ "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "@tanstack/solid-query": "5.91.4", + "@tanstack/solid-virtual": "catalog:", "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", "effect": "catalog:", "fuzzysort": "catalog:", - "ghostty-web": "github:anomalyco/ghostty-web#main", + "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", "luxon": "catalog:", "marked": "catalog:", "marked-shiki": "catalog:", @@ -76,7 +91,7 @@ "shiki": "catalog:", "solid-js": "catalog:", "solid-list": "catalog:", - "tailwindcss": "catalog:", - "virtua": "catalog:" + "solid-presence": "0.2.0", + "tailwindcss": "catalog:" } } diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index e9fb1cfe4ed7..f68652363d34 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -7,14 +7,9 @@ const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" const command = `bun run dev -- --host 0.0.0.0 --port ${port}` const reuse = !process.env.CI const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined -const reporter = [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]] as const - -if (process.env.PLAYWRIGHT_JUNIT_OUTPUT) { - reporter.push(["junit", { outputFile: process.env.PLAYWRIGHT_JUNIT_OUTPUT }]) -} - export default defineConfig({ testDir: "./e2e", + testIgnore: process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**", outputDir: "./e2e/test-results", timeout: 60_000, expect: { @@ -24,7 +19,7 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers, - reporter, + reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]], webServer: { command, url: baseURL, diff --git a/packages/app/public/oc-theme-preload.js b/packages/app/public/oc-theme-preload.js index 18846fceb6b6..11c9b39ec4cd 100644 --- a/packages/app/public/oc-theme-preload.js +++ b/packages/app/public/oc-theme-preload.js @@ -15,10 +15,11 @@ document.documentElement.dataset.theme = themeId document.documentElement.dataset.colorScheme = mode + document.documentElement.style.backgroundColor = isDark ? "#080808" : "#fafafa" // Update theme-color meta tag to match app color scheme var metas = document.querySelectorAll("meta[name='theme-color']") - if (metas.length > 0) metas[0].setAttribute("content", isDark ? "#131010" : "#F8F7F7") + if (metas.length > 0) metas[0].setAttribute("content", isDark ? "#080808" : "#fafafa") if (themeId === "oc-2") return diff --git a/packages/app/src/addons/serialize.test.ts b/packages/app/src/addons/serialize.test.ts index 6828e60f8479..d45af34c43cb 100644 --- a/packages/app/src/addons/serialize.test.ts +++ b/packages/app/src/addons/serialize.test.ts @@ -37,6 +37,14 @@ function writeAndWait(term: Terminal, data: string): Promise { } describe("SerializeAddon", () => { + test("preserves color scheme reporting mode", async () => { + const { term, addon } = createTerminal() + await writeAndWait(term, "\x1b[?2031h") + + expect(addon.serialize().startsWith("\x1b[?2031h")).toBe(true) + expect(addon.serialize({ excludeModes: true }).startsWith("\x1b[?2031h")).toBe(false) + }) + 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 3823fb443afe..515153488c1d 100644 --- a/packages/app/src/addons/serialize.ts +++ b/packages/app/src/addons/serialize.ts @@ -89,6 +89,13 @@ const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined = return { active, normal, alternate } } +const getTerminalMode = (value: ITerminalCore, mode: number) => { + if (!isRecord(value)) return false + const terminal = value.wasmTerm + if (!isRecord(terminal) || typeof terminal.getMode !== "function") return false + return terminal.getMode(mode) === true +} + // ============================================================================ // Types // ============================================================================ @@ -544,7 +551,8 @@ export class SerializeAddon implements ITerminalAddon { return "" } - let content = options?.range + let content = !options?.excludeModes && getTerminalMode(this._terminal, 2031) ? "\u001b[?2031h" : "" + content += options?.range ? this._serializeBufferByRange(normalBuffer, options.range, true) : this._serializeBufferByScrollback(normalBuffer, options?.scrollback) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index ad9aa3543ff5..0c950871a335 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -3,19 +3,29 @@ import * as Sentry from "@sentry/solid" import { I18nProvider } from "@opencode-ai/ui/context" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { FileComponentProvider } from "@opencode-ai/ui/context/file" -import { MarkedProvider } from "@opencode-ai/ui/context/marked" -import { File } from "@opencode-ai/ui/file" +import { File } from "@opencode-ai/session-ui/file" import { Font } from "@opencode-ai/ui/font" import { Splash } from "@opencode-ai/ui/logo" import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" -import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" +import { + type BaseRouterProps, + Navigate, + Route, + Router, + useLocation, + useNavigate, + useParams, + useSearchParams, +} from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { Effect } from "effect" +import { base64Encode } from "@opencode-ai/core/util/encode" import { type Component, createEffect, createMemo, + createRenderEffect, createResource, createSignal, ErrorBoundary, @@ -27,44 +37,233 @@ import { Show, } from "solid-js" import { Dynamic } from "solid-js/web" -import { CommandProvider } from "@/context/command" +import { makeEventListener } from "@solid-primitives/event-listener" +import { CommandProvider, useCommand, type CommandOption } from "@/context/command" import { CommentsProvider } from "@/context/comments" import { FileProvider } from "@/context/file" import { ServerSDKProvider } from "@/context/server-sdk" -import { ServerSyncProvider } from "@/context/server-sync" -import { GlobalProvider } from "@/context/global" +import { ServerSyncProvider, useServerSync } from "@/context/server-sync" +import { GlobalProvider, useGlobal } from "@/context/global" import { HighlightsProvider } from "@/context/highlights" import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LayoutProvider } from "@/context/layout" import { ModelsProvider } from "@/context/models" import { NotificationProvider } from "@/context/notification" import { PermissionProvider } from "@/context/permission" +import { usePlatform } from "@/context/platform" import { PromptProvider } from "@/context/prompt" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { SettingsProvider, useSettings } from "@/context/settings" -import { TerminalProvider } from "@/context/terminal" -import { TabsProvider } from "@/context/tabs" +import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs" +import { SDKProvider, useSDK } from "@/context/sdk" import { WslServersProvider } from "@/wsl/context" -import DirectoryLayout from "@/pages/directory-layout" -import Layout from "@/pages/layout" +import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout" +import LegacyLayout from "@/pages/layout" +import NewLayout from "@/pages/layout-new" import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" +import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route" +import { createSessionLineage } from "@/pages/session/session-lineage" + +import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session" +import { NewHome } from "@/pages/home" +import { LegacyHome } from "@/pages/home/legacy-home" + +const NewSession = lazy(() => import("@/pages/new-session")) + +const SessionRoute = () => { + const settings = useSettings() + const params = useParams() + const [search] = useSearchParams<{ draftId?: string; prompt?: string }>() + const sdk = useSDK() + const server = useServer() + const tabs = useTabs() + + if (params.id && settings.general.newLayoutDesigns()) { + const sessionID = params.id + return ( + + {(_) => { + const persisted = tabs.store.filter((item) => item.type === "session") + return + }} + + ) + } + + // When the new layout is enabled, the legacy new-session route (/:dir/session with no id) + // is replaced by a draft at /new-session?draftId=… + createEffect(() => { + if (!settings.general.newLayoutDesigns()) return + if (params.id || search.draftId) return + if (!tabs.ready() || !sdk().directory) return + tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt) + }) -const HomeRoute = lazy(() => import("@/pages/home")) -const Session = lazy(() => import("@/pages/session")) + return ( + + + + ) +} -const SessionRoute = Object.assign( - () => ( - - - - ), - { preload: Session.preload }, +function TargetServerRoute(props: ParentProps) { + const params = useParams<{ serverKey: string; id: string }>() + const global = useGlobal() + const conn = createMemo(() => { + const key = requireServerKey(params.serverKey) + return global.servers.list().find((item) => ServerConnection.key(item) === key) + }) + + return ( + // Owns the server-identity remount. Session changes must NOT remount this + // subtree (SessionRouteErrorBoundary resets and createSessionLineage + // re-resolves reactively instead); both rely on this key for server changes. + + + {props.children} + + + ) +} + +const TargetSessionRoute = () => ( + + + ) +function LegacyTargetSessionRoute() { + const params = useParams<{ serverKey: string; id: string }>() + return ( + + + + + + ) +} + +function LegacyTargetSessionRedirect() { + const params = useParams<{ id: string }>() + const navigate = useNavigate() + const sync = useServerSync() + const current = createSessionLineage( + () => params.id, + () => sync().session.lineage, + ) + + createEffect(() => { + const directory = current()?.session.directory + if (!directory) return + navigate(legacySessionHref(directory, params.id), { replace: true }) + }) + + return null +} + +// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected +// server via ServerKey, then provide the server-scoped shell for that server. +function SelectedServerProviders(props: ParentProps) { + return ( + + + {props.children} + + + ) +} + +function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) { + return ( + + {props.children} + + ) +} + +function DraftRoute() { + const [search] = useSearchParams<{ draftId?: string }>() + const settings = useSettings() + const tabs = useTabs() + return ( + + tab.type === "draft" && tab.draftID === search.draftId)} + keyed + fallback={} + > + {(draft) => ( + } + > + + + )} + + + ) +} + +function ResolvedDraftRoute(props: { draft: DraftTab }) { + const global = useGlobal() + const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server)) + const directory = () => props.draft.directory + const serverKey = () => props.draft.server + + return ( + + + + + + + + + + + + + + + + ) +} + function UiI18nBridge(props: ParentProps) { const language = useLanguage() - return {props.children} + return ( + + {props.children} + + ) +} + +function LayoutCompatibility(props: ParentProps) { + const global = useGlobal() + const navigate = useNavigate() + const server = useServer() + const settings = useSettings() + + createEffect(() => { + if (settings.general.newLayoutDesigns()) return + const current = server.current + if (!current) return + const protocol = global.ensureServerCtx(current).sdk.protocolKind() + if (protocol !== "v2") return + const next = global.servers.list().find((s) => { + if (ServerConnection.key(s) === ServerConnection.key(current)) return false + return global.ensureServerCtx(s).sdk.protocolKind() !== "v2" + }) + if (!next) return + navigate("/") + queueMicrotask(() => server.setActive(ServerConnection.key(next))) + }) + + return <>{props.children} } declare global { @@ -73,7 +272,7 @@ declare global { deepLinks?: string[] } api?: { - setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise + setTitlebar?: (theme: { mode: "light" | "dark"; scheme?: "system" | "light" | "dark" }) => Promise exportDebugLogs?: () => Promise } } @@ -95,10 +294,11 @@ function QueryProvider(props: ParentProps) { function BodyDesignClass() { const settings = useSettings() - createEffect(() => { + createRenderEffect(() => { if (typeof document === "undefined") return const enabled = settings.general.newLayoutDesigns() + document.body.toggleAttribute("data-new-layout", enabled) document.body.classList.toggle("text-12-regular", !enabled) document.body.classList.toggle("font-(family-name:--font-family-text)", enabled) document.body.classList.toggle("text-[13px]", enabled) @@ -108,60 +308,103 @@ function BodyDesignClass() { return null } -function AppShellProviders(props: ParentProps) { +// Server-agnostic providers shared across every route. These live in the shared +// shell (router root) so they stay mounted regardless of the active server/route. +function SharedProviders(props: ParentProps) { return ( - + <> - - - - - - - {props.children} - - - - - - - + + + {props.children} + + + ) +} + +function DesktopCommands() { + const command = useCommand() + const language = useLanguage() + const platform = usePlatform() + + command.register("desktop", () => { + const commands: CommandOption[] = [] + if (platform.platform === "desktop" && platform.exportDebugLogs) { + commands.push({ + id: "logs.export", + title: language.t("command.logs.export"), + category: language.t("command.category.settings"), + onSelect: () => { + void platform.exportDebugLogs?.() + }, + }) + } + return commands + }) + + return null +} + +// Server-scoped providers shared by the legacy shell and the top-level new shell. +type ServerScopedShellProps = ParentProps<{ + directory?: () => string | undefined + serverScoped?: JSX.Element +}> + +function ServerScopedProviders(props: ServerScopedShellProps) { + return ( + + {props.serverScoped} + {props.children} + ) } -function SessionProviders(props: ParentProps) { +function LegacyServerScopedShell(props: ServerScopedShellProps) { return ( - - - - {props.children} - - - + + {props.children} + ) } -function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { +function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) { return ( - - {/*}>*/} - {props.appChildren} - {props.children} - {/**/} - + + + {props.children} + + ) } -export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { +// The draft page only renders the prompt composer, so it drops TerminalProvider. +// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context. +function DraftProviders(props: ParentProps) { + return ( + + + {props.children} + + + ) +} + +export function AppBaseProviders( + props: ParentProps<{ + locale?: Locale + onNativeTranslations?: Parameters[0]["onNativeTranslations"] + }>, +) { return ( { - void window.api?.setTitlebar?.({ mode }) + onThemeApplied={(_, mode, scheme) => { + void window.api?.setTitlebar?.({ mode, scheme }) }} > - + { @@ -172,9 +415,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { - - {props.children} - + {props.children} @@ -186,7 +427,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { ) } -function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { +function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; startup?: Promise }>) { const server = useServer() const checkServerHealth = useCheckServerHealth() @@ -215,34 +456,45 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { const checking = createMemo( () => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state), ) + const [startup] = createResource(async () => { + if (!props.startup) return true + await props.startup.catch((error) => { + console.error("[startup] startup gate failed", error) + }) + return true + }) + const startupChecking = createMemo( + () => startupHealthCheck.latest === true && ["unresolved", "pending"].includes(startup.state), + ) + const loading = createMemo(() => checking() || startupChecking()) return ( - + <> + + { + if (checkMode() === "background") void healthCheckActions.refetch() + }} + onServerSelected={(key) => { + setCheckMode("blocking") + server.setActive(key) + void healthCheckActions.refetch() + }} + /> + } + > + {props.children} + + + +
- } - > - { - if (checkMode() === "background") void healthCheckActions.refetch() - }} - onServerSelected={(key) => { - setCheckMode("blocking") - server.setActive(key) - void healthCheckActions.refetch() - }} - /> - } - > - {props.children} -
+ ) } @@ -309,7 +561,21 @@ export function AppInterface(props: { servers?: Array router?: Component disableHealthCheck?: boolean + startup?: Promise + serverScoped?: JSX.Element }) { + // The visual new layout lives in the router root so it remains mounted across + // route changes. Draft and session routes override only their server-bound data + // providers beneath it. + const ServerShell = (shellProps: ParentProps) => ( + + + {props.children} + {shellProps.children} + + + ) + return ( - - ( - - - - - - {routerProps.children} - - - - - - )} - > - - - } /> - - - - + + + + ( + + + + + + {routerProps.children} + + + + + + )} + > + + + + + ) } + +function Routes(props: { serverScoped?: JSX.Element }) { + const settings = useSettings() + + return ( + <> + ( + {routeProps.children} + )} + > + + { + <> + + + + } + + + } /> + + + + + + + + + + + ) +} + +function NewLayoutLegacySessionRedirect() { + const server = useServer() + const tabs = useTabs() + const params = useParams<{ id: string }>() + + return ( + + item.type === "session"), + params.id, + server.key, + ), + params.id, + )} + /> + + ) +} diff --git a/packages/app/src/assets/help/home.png b/packages/app/src/assets/help/home.png new file mode 100644 index 000000000000..aeca6d977a89 Binary files /dev/null and b/packages/app/src/assets/help/home.png differ diff --git a/packages/app/src/assets/help/introducing-tabs.mp4 b/packages/app/src/assets/help/introducing-tabs.mp4 new file mode 100644 index 000000000000..fd46be1f46f0 Binary files /dev/null and b/packages/app/src/assets/help/introducing-tabs.mp4 differ diff --git a/packages/app/src/assets/help/placeholder.png b/packages/app/src/assets/help/placeholder.png new file mode 100644 index 000000000000..c651d06cc920 Binary files /dev/null and b/packages/app/src/assets/help/placeholder.png differ diff --git a/packages/app/src/assets/help/tabs.png b/packages/app/src/assets/help/tabs.png new file mode 100644 index 000000000000..d2c6e68a3da1 Binary files /dev/null and b/packages/app/src/assets/help/tabs.png differ diff --git a/packages/app/src/components/command-palette.ts b/packages/app/src/components/command-palette.ts new file mode 100644 index 000000000000..8014ea1c4395 --- /dev/null +++ b/packages/app/src/components/command-palette.ts @@ -0,0 +1,281 @@ +import { getFilename } from "@opencode-ai/core/util/path" +import type { Project } from "@opencode-ai/sdk/v2/client" +import type { SessionInfo } from "@opencode-ai/client/promise" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createMemo, onCleanup } from "solid-js" +import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command" +import { useFile } from "@/context/file" +import { useGlobal } from "@/context/global" +import { useLanguage } from "@/context/language" +import { useLayout, type LocalProject } from "@/context/layout" +import { ServerConnection } from "@/context/server" +import { useServerSDK } from "@/context/server-sdk" +import { useTabs } from "@/context/tabs" +import { displayName, projectForSession } from "@/pages/layout/helpers" +import { createSessionTabs } from "@/pages/session/helpers" +import { useSessionLayout } from "@/pages/session/session-layout" +import { normalizeSessionInfo } from "@/utils/session" + +export type CommandPaletteEntry = { + id: string + type: "command" | "file" | "session" + title: string + description?: string + keybind?: string + category: string + option?: CommandOption + path?: string + directory?: string + sessionID?: string + server?: ServerConnection.Key + project?: LocalProject + archived?: number + updated?: number +} + +const ENTRY_LIMIT = 5 +const COMMON_COMMAND_IDS = [ + "session.new", + "workspace.new", + "session.previous", + "session.next", + "terminal.toggle", + "review.toggle", +] as const + +export function uniqueCommandPaletteEntries(items: CommandPaletteEntry[]) { + const seen = new Set() + return items.filter((item) => { + if (seen.has(item.id)) return false + seen.add(item.id) + return true + }) +} + +export function createCommandPaletteFileEntry(path: string, category: string): CommandPaletteEntry { + return { + id: "file:" + path, + type: "file", + title: path, + category, + path, + } +} + +export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => void) { + const file = useFile() + const layout = useLayout() + const { tabs, view } = useSessionLayout() + + return (path: string) => { + const value = file.tab(path) + void tabs().open(value) + void file.load(path) + if (!view().reviewPanel.opened()) view().reviewPanel.open() + layout.fileTree.setTab("all") + onOpenFile?.(path) + tabs().setActive(value) + } +} + +export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) { + const command = useCommand() + const global = useGlobal() + const language = useLanguage() + const file = useFile() + const dialog = useDialog() + const serverSDK = useServerSDK()() + const serverCtx = global.ensureServerCtx(serverSDK.server) + const appTabs = useTabs() + const { tabs: sessionTabs } = useSessionLayout() + const openFile = createCommandPaletteFileOpener(props.onOpenFile) + const state = { cleanup: undefined as (() => void) | void, committed: false } + const filesOnly = () => props.filesOnly?.() ?? false + + const allowedCommands = createMemo(() => { + if (filesOnly()) return [] + return commandPaletteOptions(command.options) + }) + const commandEntries = createMemo(() => { + const category = language.t("palette.group.commands") + return allowedCommands().map((option) => createCommandPaletteCommandEntry(option, category)) + }) + const preferredCommandEntries = createMemo(() => { + const all = allowedCommands() + const order = new Map(COMMON_COMMAND_IDS.map((id, index) => [id, index])) + const picked = all.filter((option) => order.has(option.id)) + const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT) + const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base + const category = language.t("palette.group.commands") + return sorted.map((option) => createCommandPaletteCommandEntry(option, category)) + }) + + const tabState = createSessionTabs({ + tabs: sessionTabs, + pathFromTab: file.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), + }) + const recentFileEntries = createMemo(() => { + const all = tabState.openedTabs() + const active = tabState.activeFileTab() + const order = active ? [active, ...all.filter((item) => item !== active)] : all + const seen = new Set() + const category = language.t("palette.group.files") + return order + .map((item) => file.pathFromTab(item)) + .filter((path): path is string => { + if (!path || seen.has(path)) return false + seen.add(path) + return true + }) + .slice(0, ENTRY_LIMIT) + .map((path) => createCommandPaletteFileEntry(path, category)) + }) + const rootFileEntries = createMemo(() => { + const category = language.t("palette.group.files") + return file.tree + .children("") + .filter((node) => node.type === "file") + .map((node) => node.path) + .sort((a, b) => a.localeCompare(b)) + .slice(0, ENTRY_LIMIT) + .map((path) => createCommandPaletteFileEntry(path, category)) + }) + + const sessions = createServerSessionEntries({ + server: ServerConnection.key(serverSDK.server), + opened: serverCtx.projects.list, + stored: () => serverCtx.sync.data.project, + load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }), + untitled: () => language.t("command.session.new"), + category: () => language.t("command.category.session"), + }) + + const highlight = (item: CommandPaletteEntry | undefined) => { + state.cleanup?.() + state.cleanup = undefined + if (item?.type !== "command") return + state.cleanup = item.option?.onHighlight?.() + } + + const select = (item: CommandPaletteEntry | undefined) => { + if (!item) return + state.committed = true + state.cleanup = undefined + dialog.close() + if (item.type === "command") { + item.option?.onSelect?.("palette") + return + } + if (item.type === "session") { + if (!item.sessionID || !item.server) return + const directory = item.project?.worktree ?? item.directory + if (directory) { + serverCtx.projects.open(directory) + serverCtx.projects.touch(directory) + } + const tab = appTabs.addSessionTab({ + server: item.server, + sessionId: item.sessionID, + }) + appTabs.select(tab) + return + } + if (!item.path) return + openFile(item.path) + } + + onCleanup(() => { + if (state.committed) return + state.cleanup?.() + }) + + return { + language, + file, + commandEntries, + preferredCommandEntries, + recentFileEntries, + rootFileEntries, + sessions, + highlight, + select, + close: () => dialog.close(), + } +} + +export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry { + return { + id: "command:" + option.id, + type: "command", + title: option.title, + description: option.description, + keybind: option.keybind, + category, + option, + } +} + +export function createServerSessionEntries(props: { + server: ServerConnection.Key + opened: () => LocalProject[] + stored: () => Project[] + load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }> + untitled: () => string + category: () => string +}) { + let abort: AbortController | undefined + + onCleanup(() => abort?.abort()) + + return async (text: string): Promise => { + const search = text.trim() + if (!search) { + abort?.abort() + return [] + } + abort?.abort() + const current = new AbortController() + abort = current + await new Promise((resolve) => { + const timer = setTimeout(resolve, 100) + current.signal.addEventListener( + "abort", + () => { + clearTimeout(timer) + resolve() + }, + { once: true }, + ) + }) + if (current.signal.aborted) return [] + const opened = props.opened() + const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : []))) + const stored = props.stored().map((project) => ({ ...project, expanded: false })) + const storedByID = new Map(stored.map((project) => [project.id, project] as const)) + return props + .load(search, current.signal) + .then((result) => + result.data + .map(normalizeSessionInfo) + .filter((session) => !session.time.archived) + .map((session) => { + const project = + projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID) + return { + id: `session:${props.server}:${session.id}`, + type: "session" as const, + title: session.title || props.untitled(), + description: project ? displayName(project) : getFilename(session.directory), + category: props.category(), + directory: session.directory, + sessionID: session.id, + server: props.server, + project, + updated: session.time.updated, + } + }), + ) + .catch(() => [] as CommandPaletteEntry[]) + } +} diff --git a/packages/app/src/components/command-tooltip-keybind.test.ts b/packages/app/src/components/command-tooltip-keybind.test.ts new file mode 100644 index 000000000000..63c7f32468e1 --- /dev/null +++ b/packages/app/src/components/command-tooltip-keybind.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { newTabTooltipKeybind, reviewTooltipKeybind } from "./command-tooltip-keybind" + +describe("command tooltip keybinds", () => { + test("keeps localized review shortcut modifiers", () => { + const command = { + keybind: () => "Ctrl+Maj+R", + keybindParts: () => ["Ctrl", "Maj", "R"], + } + + expect(reviewTooltipKeybind(command, (key) => key)).toEqual(["Ctrl", "Maj", "R"]) + }) + + test("uses the configured new-tab shortcut", () => { + const command = { + keybind: () => "Alt+N", + keybindParts: () => ["Alt", "N"], + } + + expect(newTabTooltipKeybind(command, (key) => key)).toEqual(["Alt", "N"]) + }) +}) diff --git a/packages/app/src/components/command-tooltip-keybind.ts b/packages/app/src/components/command-tooltip-keybind.ts new file mode 100644 index 000000000000..d6685b95a8f0 --- /dev/null +++ b/packages/app/src/components/command-tooltip-keybind.ts @@ -0,0 +1,11 @@ +type CommandKeybind = { + keybindParts: (id: string) => string[] +} + +export function reviewTooltipKeybind(command: CommandKeybind, _translate?: (key: string) => string) { + return command.keybindParts("review.toggle") +} + +export function newTabTooltipKeybind(command: CommandKeybind, _translate?: (key: string) => string) { + return command.keybindParts("tab.new") +} diff --git a/packages/app/src/components/debug-bar.tsx b/packages/app/src/components/debug-bar.tsx index 11f9f59e4e09..9465374cbb06 100644 --- a/packages/app/src/components/debug-bar.tsx +++ b/packages/app/src/components/debug-bar.tsx @@ -3,7 +3,9 @@ import { batch, createEffect, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { makeEventListener } from "@solid-primitives/event-listener" import { Tooltip } from "@opencode-ai/ui/tooltip" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" type Mem = Performance & { memory?: { @@ -51,19 +53,45 @@ const bad = (n: number | undefined, limit: number, low = false) => { const session = (path: string) => path.includes("/session") -function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string; wide?: boolean }) { - return ( - +function Cell(props: { + bad?: boolean + dim?: boolean + inline?: boolean + label: string + tip: string + value: string + span?: 2 | 3 +}) { + const content = () => ( +
-
{props.label}
+ {props.label} +
+
+
+ ) + + if (props.inline) { + return ( + + {content()} + + ) + } + + return ( + + {content()} + + ) +} + +function ToggleCell(props: { + active: boolean + inline?: boolean + label: string + onClick: () => void + tip: string + value: string +}) { + const content = () => ( + + ) + + if (props.inline) { + return ( + + {content()} + + ) + } + + return ( + + {content()} ) } -export function DebugBar() { +export function DebugBar(props: { inline?: boolean } = {}) { const language = useLanguage() + const platform = usePlatform() const location = useLocation() const routing = useIsRouting() const [state, setState] = createStore({ @@ -84,6 +176,7 @@ export function DebugBar() { delay: undefined as number | undefined, fps: undefined as number | undefined, gap: undefined as number | undefined, + focus: false, heap: { limit: undefined as number | undefined, used: undefined as number | undefined, @@ -101,7 +194,7 @@ export function DebugBar() { }, }) - const na = () => language.t("debugBar.na") + const na = () => language.t("debugBar.na").toUpperCase() const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined) const heapv = () => { const value = heap() @@ -110,6 +203,16 @@ export function DebugBar() { } const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`) const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na())) + const toggleFocus = async () => { + if (!platform.setForceFocus) return + const enabled = !state.focus + await platform.setForceFocus(enabled) + setState("focus", enabled) + } + + onCleanup(() => { + if (state.focus) void platform.setForceFocus?.(false).catch(() => undefined) + }) let prev = "" let start = 0 @@ -363,15 +466,30 @@ export function DebugBar() { return ( ) diff --git a/packages/app/src/components/dialog-command-palette-v2.css b/packages/app/src/components/dialog-command-palette-v2.css new file mode 100644 index 000000000000..a4019928aa1e --- /dev/null +++ b/packages/app/src/components/dialog-command-palette-v2.css @@ -0,0 +1,220 @@ +.command-palette-v2 { + overflow: hidden; +} + +/* Anchor to the top edge of where a centered 480px-tall dialog would sit, so the + top stays put while the content-driven height grows and shrinks. */ +[data-component="dialog-v2"]:has(.command-palette-v2) { + align-items: flex-start; +} + +[data-component="dialog-v2"]:has(.command-palette-v2) [data-slot="dialog-container"] { + width: min(calc(100vw - 24px), 640px); + height: auto; + min-height: 280px; + max-height: min(calc(100vh - 96px), 480px); + margin-top: max(48px, calc((100vh - 480px) / 2)); + border-radius: 12px; + background: var(--v2-background-bg-base); + box-shadow: var(--v2-elevation-floating); +} + +.command-palette-v2-body { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: 0; + padding: 0; +} + +.command-palette-v2-search { + flex-shrink: 0; + padding: 6px; +} + +.command-palette-v2-search [data-component="text-input-v2"] { + width: 100%; + height: 36px; + border-radius: 6px; + outline: 0; + background: color-mix(in srgb, var(--v2-background-bg-layer-02) 60%, transparent); + box-shadow: none; + transition: + background-color 120ms ease-in-out, + box-shadow 120ms ease-in-out; +} + +.command-palette-v2-search [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]), +.command-palette-v2-search [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) { + background: var(--v2-background-bg-layer-02); + box-shadow: none; +} + +.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] { + border-radius: 6px; + background: transparent; + gap: 8px; +} + +.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] { + padding-left: 12px; +} + +.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] { + border-radius: 6px; + background: transparent; +} + +.command-palette-v2-scroll { + min-height: 0; + flex: 1; +} + +.command-palette-v2-results { + display: flex; + flex-direction: column; + gap: 16px; + padding: 6px 6px 8px; +} + +.command-palette-v2-group { + display: flex; + flex-direction: column; + gap: 1px; +} + +.command-palette-v2-group-title { + margin: 6px 0; + padding: 0 12px; + color: var(--v2-text-text-muted); + font-size: 13px; + font-weight: 440; + line-height: 16px; + letter-spacing: -0.04px; + user-select: none; +} + +.command-palette-v2-row { + display: flex; + width: 100%; + height: 36px; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 12px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--v2-text-text-base); + text-align: left; + cursor: default; + scroll-margin: 6px 0; +} + +.command-palette-v2-row[data-active] { + background: var(--v2-overlay-simple-overlay-hover); +} + +.command-palette-v2-row:focus-visible { + background: var(--v2-overlay-simple-overlay-hover); + outline: none; +} + +.command-palette-v2-row-main { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + gap: 8px; +} + +.command-palette-v2-row-icon { + flex-shrink: 0; + color: var(--v2-icon-icon-muted); +} + +.command-palette-v2-row-text { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; +} + +.command-palette-v2-title { + min-width: 0; + overflow: hidden; + color: var(--v2-text-text-base); + font-size: 13px; + font-weight: 530; + line-height: 16px; + letter-spacing: -0.04px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.command-palette-v2-description, +.command-palette-v2-meta { + min-width: 0; + overflow: hidden; + color: var(--v2-text-text-muted); + font-size: 13px; + font-weight: 440; + line-height: 16px; + letter-spacing: -0.04px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.command-palette-v2-meta { + flex-shrink: 0; +} + +.command-palette-v2-file-path { + display: flex; + min-width: 0; + align-items: baseline; + font-size: 13px; + line-height: 16px; + letter-spacing: -0.04px; +} + +.command-palette-v2-file-dir { + min-width: 0; + overflow: hidden; + color: var(--v2-text-text-muted); + font-weight: 440; + text-overflow: ellipsis; + white-space: nowrap; +} + +.command-palette-v2-file-name { + flex-shrink: 0; + color: var(--v2-text-text-base); + font-weight: 530; + white-space: nowrap; +} + +.command-palette-v2-state { + display: grid; + min-height: 120px; + place-items: center; + color: var(--v2-text-text-muted); + font-size: 13px; + font-weight: 440; + line-height: 16px; + letter-spacing: -0.04px; +} + +@media (max-width: 640px) { + .command-palette-v2-row-text { + flex-direction: column; + align-items: flex-start; + gap: 1px; + } + + .command-palette-v2-description { + max-width: 100%; + } +} diff --git a/packages/app/src/components/dialog-command-palette-v2.tsx b/packages/app/src/components/dialog-command-palette-v2.tsx new file mode 100644 index 000000000000..e996fd0be77c --- /dev/null +++ b/packages/app/src/components/dialog-command-palette-v2.tsx @@ -0,0 +1,342 @@ +import { getDirectory, getFilename } from "@opencode-ai/core/util/path" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { ScrollView } from "@opencode-ai/ui/scroll-view" +import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js" +import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command" +import { useGlobal } from "@/context/global" +import { useLanguage } from "@/context/language" +import { ServerConnection } from "@/context/server" +import { useTabs } from "@/context/tabs" +import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" +import { getRelativeTime } from "@/utils/time" +import { + createCommandPaletteCommandEntry, + createCommandPaletteFileEntry, + createCommandPaletteModel, + createServerSessionEntries, + uniqueCommandPaletteEntries, + type CommandPaletteEntry, +} from "./command-palette" +import "./dialog-command-palette-v2.css" + +function groups(entries: CommandPaletteEntry[]) { + const map = new Map() + for (const entry of entries) map.set(entry.category, [...(map.get(entry.category) ?? []), entry]) + return Array.from(map.entries()).map(([category, entries]) => ({ category, entries })) +} + +function matchesEntry(entry: CommandPaletteEntry, query: string) { + const value = query.toLowerCase() + return [entry.title, entry.description, entry.category].some((text) => text?.toLowerCase().includes(value)) +} + +export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) { + const palette = createCommandPaletteModel(props) + const loadItems = async (text: string) => { + const q = text.trim() + if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()] + + const [files, nextSessions] = await Promise.all([palette.file.searchFiles(q), Promise.resolve(palette.sessions(q))]) + const category = palette.language.t("palette.group.files") + return [ + ...palette.commandEntries().filter((entry) => matchesEntry(entry, q)), + ...nextSessions, + ...files.map((path) => createCommandPaletteFileEntry(path, category)), + ] + } + + return ( + + ) +} + +export function DialogHomeCommandPaletteV2(props: { + server: ServerConnection.Any + onSelectSession: (entry: CommandPaletteEntry) => void +}) { + const command = useCommand() + const dialog = useDialog() + const global = useGlobal() + const language = useLanguage() + const serverCtx = global.ensureServerCtx(props.server) + const state = { cleanup: undefined as (() => void) | void, committed: false } + const commandEntries = createMemo(() => { + const category = language.t("palette.group.commands") + return commandPaletteOptions(command.options).map((option) => createCommandPaletteCommandEntry(option, category)) + }) + const sessions = createServerSessionEntries({ + server: ServerConnection.key(props.server), + opened: serverCtx.projects.list, + stored: () => serverCtx.sync.data.project, + load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }), + untitled: () => language.t("command.session.new"), + category: () => language.t("command.category.session"), + }) + + const highlight = (item: CommandPaletteEntry | undefined) => { + state.cleanup?.() + state.cleanup = undefined + if (item?.type !== "command") return + state.cleanup = item.option?.onHighlight?.() + } + const select = (item: CommandPaletteEntry | undefined) => { + if (!item) return + state.committed = true + state.cleanup = undefined + dialog.close() + if (item.type === "command") { + item.option?.onSelect?.("palette") + return + } + if (item.type === "session") props.onSelectSession(item) + } + const loadItems = async (text: string) => { + const query = text.trim() + if (!query) return commandEntries().slice(0, 5) + return [...commandEntries().filter((entry) => matchesEntry(entry, query)), ...(await sessions(query))] + } + + onCleanup(() => { + if (state.committed) return + state.cleanup?.() + }) + + return ( + dialog.close()} + /> + ) +} + +function CommandPaletteView(props: { + placeholder: string + loadItems: (text: string) => CommandPaletteEntry[] | Promise + highlight: (item: CommandPaletteEntry | undefined) => void + select: (item: CommandPaletteEntry | undefined) => void + close: () => void +}) { + const language = useLanguage() + const tabs = useTabs() + const [query, setQuery] = createSignal("") + const [active, setActive] = createSignal(0) + + const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] }) + // Render stale results while a new query loads to avoid flashing "Loading" per keystroke. + const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? [])) + const groupedEntries = createMemo(() => groups(visibleEntries())) + const activeEntry = createMemo(() => visibleEntries()[active()]) + const openSessions = createMemo( + () => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))), + ) + + createEffect(() => { + query() + visibleEntries() + setActive(0) + }) + + createEffect(() => { + props.highlight(activeEntry()) + }) + + let resultsRef: HTMLDivElement | undefined + + const move = (delta: -1 | 1) => { + const count = visibleEntries().length + if (count === 0) return + setActive((index) => (index + delta + count) % count) + requestAnimationFrame(() => { + resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" }) + }) + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "ArrowDown") { + event.preventDefault() + move(1) + return + } + if (event.key === "ArrowUp") { + event.preventDefault() + move(-1) + return + } + if (event.key === "Enter") { + event.preventDefault() + props.select(activeEntry()) + return + } + if (event.key === "Escape") { + event.preventDefault() + props.close() + } + } + + return ( + + + + (resultsRef = el)}> +
+ 0} + fallback={ +
+ {entries.loading ? language.t("common.loading") : language.t("palette.empty")} +
+ } + > + + {(group) => ( +
+ +
{group.category}
+
+ + {(item) => ( + setActive(visibleEntries().findIndex((entry) => entry.id === item.id))} + onSelect={() => props.select(item)} + /> + )} + +
+ )} +
+
+
+
+
+
+ ) +} + +function PaletteRow(props: { + item: CommandPaletteEntry + active: boolean + language: ReturnType + sessionOpen: boolean + onActive: () => void + onSelect: () => void +}) { + const session = () => + props.item.server && props.item.directory && props.item.sessionID + ? { server: props.item.server, directory: props.item.directory, sessionID: props.item.sessionID } + : undefined + + return ( +
+ } + > + +
+
+ {props.item.title} + + {props.item.description} + +
+
+ + + +
+ +
+
+ + + + {(session) => ( + + )} + +
+
+ + {props.item.title} + + + + {props.item.description} + + +
+
+ + + {getRelativeTime(new Date(props.item.updated!).toISOString(), props.language.t)} + + +
+ + + ) +} diff --git a/packages/app/src/components/dialog-connect-provider.stories.tsx b/packages/app/src/components/dialog-connect-provider.stories.tsx new file mode 100644 index 000000000000..3aec6c9de332 --- /dev/null +++ b/packages/app/src/components/dialog-connect-provider.stories.tsx @@ -0,0 +1,73 @@ +// @ts-nocheck +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" +import { mockProviderAuth } from "@/context/server-sync" +import { onCleanup, onMount } from "solid-js" +import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider" + +function ConnectProviderDialogStory() { + const dialog = useDialog() + const open = () => dialog.show(() => ) + + onMount(open) + + return ( + + ) +} + +function ProviderConnectionDialogStory(props) { + onCleanup(mockProviderAuth(props.provider, props.methods)) + const dialog = useDialog() + const controller = useProviderConnectController() + controller.select(props.provider) + const open = () => dialog.show(() => ) + + onMount(open) + + return ( + + ) +} + +function renderConnection(provider, methods) { + return () => ( + + + + ) +} + +export default { + title: "App/Dialogs/Connect Provider", + id: "app-dialog-connect-provider", +} + +export const V2 = { + render: () => ( + + + + ), +} + +export const ApiKey = { + render: renderConnection("openrouter", [{ type: "api", label: "API key" }]), +} + +export const OpenCodeZen = { + render: renderConnection("opencode", [{ type: "api", label: "API key" }]), +} + +export const LoginMethods = { + render: renderConnection("openai", [ + { type: "oauth", label: "ChatGPT Pro/Plus (browser)" }, + { type: "oauth", label: "ChatGPT Pro/Plus (headless)" }, + { type: "api", label: "API key" }, + ]), +} diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 4d477ea273f3..aa1f519984bb 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -1,4 +1,4 @@ -import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" +import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -7,27 +7,392 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { List, type ListRef } from "@opencode-ai/ui/list" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Spinner } from "@opencode-ai/ui/spinner" +import { Tag } from "@opencode-ai/ui/tag" import { TextField } from "@opencode-ai/ui/text-field" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { showToast } from "@/utils/toast" -import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js" +import { + type Accessor, + type Component, + createEffect, + createMemo, + createResource, + createUniqueId, + For, + Match, + onCleanup, + onMount, + Show, + Switch, +} from "solid-js" import { createStore, produce } from "solid-js/store" -import { Link } from "@/components/link" +import { useParams } from "@solidjs/router" +import { ExternalLink } from "@/components/external-link" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" -import { useProviders } from "@/hooks/use-providers" +import { useSettings } from "@/context/settings" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { CustomProviderForm } from "./dialog-custom-provider" +import { decode64 } from "@/utils/base64" -export function DialogConnectProvider(props: { provider: string }) { +const CUSTOM_ID = "_custom" +type ConnectMethod = Extract + +export function useProviderConnectController(options: { onBack?: () => void } = {}) { + const [store, setStore] = createStore({ selected: undefined as string | undefined }) + const reset = () => setStore("selected", undefined) + + return { + selected: () => store.selected, + select: (provider?: string) => setStore("selected", provider), + back: options.onBack ?? reset, + } +} + +export const DialogConnectProvider: Component<{ + directory?: Accessor + controller?: ReturnType +}> = (props) => { + const fallback = useProviderConnectController() + const controller = props.controller ?? fallback + const language = useLanguage() + const settings = useSettings() + const newLayout = settings.general.newLayoutDesigns + const reset = controller.back + const back = { current: reset } + let focusHost: HTMLDivElement | undefined + const holdFocus = () => focusHost?.focus({ preventScroll: true }) + const select = (provider?: string) => { + back.current = reset + controller.select(provider) + } + + function Content() { + return ( + + + + + + {(provider) => ( + (back.current = handler)} + /> + )} + + + + + + ) + } + + return ( + + back.current()} + aria-label={language.t("common.goBack")} + /> + + } + > + + + } + > + + + {language.t("command.provider.connect")}} + > + + + + +
+ +
+
+
+
+ ) +} + +function ProviderPicker(props: { + directory?: Accessor + onSelect: (provider: string) => void + onPrepare?: () => void +}) { + const settings = useSettings() + if (settings.general.newLayoutDesigns()) + return + const providers = useProviders(() => props.directory?.()) + const language = useLanguage() + const popularGroup = () => language.t("dialog.provider.group.popular") + const otherGroup = () => language.t("dialog.provider.group.other") + const customLabel = () => language.t("settings.providers.tag.custom") + const note = (id: string) => { + if (id === "anthropic") return language.t("dialog.provider.anthropic.note") + if (id === "openai") return language.t("dialog.provider.openai.note") + if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note") + if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline") + return undefined + } + + return ( + x?.id} + items={() => { + language.locale() + return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()] + }} + filterKeys={["id", "name"]} + groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())} + sortBy={(a, b) => { + if (a.id === CUSTOM_ID) return -1 + if (b.id === CUSTOM_ID) return 1 + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + sortGroupsBy={(a, b) => { + const popular = popularGroup() + if (a.category === popular && b.category !== popular) return -1 + if (b.category === popular && a.category !== popular) return 1 + return 0 + }} + onSelect={(x) => { + if (!x) return + props.onSelect(x.id) + }} + > + {(i) => ( +
+ + {i.name} + +
{language.t("dialog.provider.opencode.tagline")}
+
+ + {language.t("settings.providers.tag.custom")} + + + {language.t("dialog.provider.tag.recommended")} + + {(value) =>
{value()}
}
+ + {language.t("dialog.provider.tag.recommended")} + +
+ )} +
+ ) +} + +function ProviderPickerV2(props: { + directory?: Accessor + onSelect: (provider: string) => void + onPrepare?: () => void +}) { + const providers = useProviders(() => props.directory?.()) + const language = useLanguage() + const [store, setStore] = createStore({ + filter: "", + active: undefined as string | undefined, + connecting: undefined as string | undefined, + }) + const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"] + const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") }) + const all = createMemo(() => { + language.locale() + const query = store.filter.trim().toLowerCase() + const values = [custom(), ...providers.all().values()] + if (!query) return values + return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query)) + }) + const popular = createMemo(() => + all() + .filter((provider) => featured.includes(provider.id)) + .sort((a, b) => featured.indexOf(a.id) - featured.indexOf(b.id)), + ) + const other = createMemo(() => + all() + .filter((provider) => !featured.includes(provider.id)) + .sort((a, b) => { + if (a.id === CUSTOM_ID) return -1 + if (b.id === CUSTOM_ID) return 1 + return a.name.localeCompare(b.name) + }), + ) + const rows = createMemo(() => [...popular(), ...other()]) + let picker: HTMLDivElement | undefined + let search: HTMLInputElement | undefined + + onMount(() => search?.focus({ preventScroll: true })) + + const connect = (provider: string) => { + props.onPrepare?.() + props.onSelect(provider) + } + + const move = (event: KeyboardEvent, direction: number) => { + const items = rows() + if (items.length === 0) return + const index = items.findIndex((provider) => provider.id === store.active) + const next = index < 0 ? (direction > 0 ? 0 : items.length - 1) : (index + direction + items.length) % items.length + setStore("active", items[next].id) + picker + ?.querySelector(`[data-provider-id="${CSS.escape(items[next].id)}"]`) + ?.focus({ preventScroll: true }) + event.preventDefault() + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "ArrowDown") return move(event, 1) + if (event.key === "ArrowUp") return move(event, -1) + if (event.key !== "Enter" || !store.active) return + connect(store.active) + event.preventDefault() + } + + return ( +
+
+ } + placeholder={language.t("dialog.provider.search.placeholder")} + value={store.filter} + onInput={(event) => { + setStore({ filter: event.currentTarget.value, active: undefined }) + }} + /> +
+
+
+ + {(group) => ( + 0}> +
+
+ {group.title} +
+ + {(provider) => ( + + )} + +
+
+ )} +
+ +
+ {language.t("dialog.provider.empty")} +
+
+
+
+
+
+ ) +} + +function ProviderConnection(props: { + provider: string + directory?: Accessor + onBack: () => void + setBack: (handler: () => void) => void +}) { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() + const params = useParams() const language = useLanguage() - const providers = useProviders() - - const all = () => { - void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) - }) + const settings = useSettings() + const newLayout = settings.general.newLayoutDesigns + const providers = useProviders(() => props.directory?.()) + const directory = () => props.directory?.() ?? decode64(params.dir) + const location = () => { + const value = directory() + return value ? { directory: value } : undefined } const alive = { value: true } @@ -41,30 +406,35 @@ export function DialogConnectProvider(props: { provider: string }) { }) const provider = createMemo( - () => providers.all().get(props.provider) ?? serverSync.data.provider.all.get(props.provider)!, + () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!, ) - const fallback = createMemo(() => [ + const fallback = createMemo(() => [ { - type: "api" as const, + type: "key" as const, label: language.t("provider.connect.method.apiKey"), }, ]) - const [auth] = createResource( - () => props.provider, - async () => { - const cached = serverSync.data.provider_auth[props.provider] - if (cached) return cached - const res = await serverSDK.client.provider.auth() - if (!alive.value) return fallback() - serverSync.set("provider_auth", res.data ?? {}) - return res.data?.[props.provider] ?? fallback() - }, + const [integration] = createResource( + () => ({ provider: props.provider, directory: directory() }), + (input) => + serverSDK() + .api.integration.get({ + integrationID: input.provider, + location: input.directory ? { directory: input.directory } : undefined, + }) + .then((result) => result.data), ) - const loading = createMemo(() => auth.loading && !serverSync.data.provider_auth[props.provider]) - const methods = createMemo(() => auth.latest ?? serverSync.data.provider_auth[props.provider] ?? fallback()) + const loading = createMemo(() => integration.loading) + const methods = createMemo(() => { + const values = integration.latest?.methods.filter( + (method): method is ConnectMethod => method.type === "key" || method.type === "oauth", + ) + return values?.length ? values : fallback() + }) const [store, setStore] = createStore({ methodIndex: undefined as undefined | number, - authorization: undefined as undefined | ProviderAuthAuthorization, + authorization: undefined as undefined | IntegrationOauthConnectOutput["data"], + promptInputs: undefined as undefined | Record, state: "pending" as undefined | "pending" | "complete" | "error" | "prompt", error: undefined as string | undefined, }) @@ -73,8 +443,9 @@ export function DialogConnectProvider(props: { provider: string }) { | { type: "method.select"; index: number } | { type: "method.reset" } | { type: "auth.prompt" } + | { type: "auth.inputs"; inputs: Record } | { type: "auth.pending" } - | { type: "auth.complete"; authorization: ProviderAuthAuthorization } + | { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] } | { type: "auth.error"; error: string } function dispatch(action: Action) { @@ -83,6 +454,7 @@ export function DialogConnectProvider(props: { provider: string }) { if (action.type === "method.select") { draft.methodIndex = action.index draft.authorization = undefined + draft.promptInputs = undefined draft.state = undefined draft.error = undefined return @@ -90,6 +462,7 @@ export function DialogConnectProvider(props: { provider: string }) { if (action.type === "method.reset") { draft.methodIndex = undefined draft.authorization = undefined + draft.promptInputs = undefined draft.state = undefined draft.error = undefined return @@ -99,6 +472,12 @@ export function DialogConnectProvider(props: { provider: string }) { draft.error = undefined return } + if (action.type === "auth.inputs") { + draft.promptInputs = action.inputs + draft.state = undefined + draft.error = undefined + return + } if (action.type === "auth.pending") { draft.state = "pending" draft.error = undefined @@ -120,10 +499,25 @@ export function DialogConnectProvider(props: { provider: string }) { const methodLabel = (value?: { type?: string; label?: string }) => { if (!value) return "" - if (value.type === "api") return language.t("provider.connect.method.apiKey") + if (value.type === "key") return language.t("provider.connect.method.apiKey") return value.label ?? "" } + const methodDetails = (value?: { type?: string; label?: string }) => { + const label = methodLabel(value) + const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i) + const hint = suffix?.[1] + return { + label: suffix ? label.slice(0, -suffix[0].length) : label, + hint: + hint?.toLowerCase() === "headless" + ? language.t("provider.connect.method.headless") + : hint?.toLowerCase() === "browser" || (!hint && value?.type === "key") + ? language.t("provider.connect.method.browser") + : undefined, + } + } + function formatError(value: unknown, fallback: string): string { if (value && typeof value === "object" && "data" in value) { const data = (value as { data?: { message?: unknown } }).data @@ -157,31 +551,16 @@ export function DialogConnectProvider(props: { provider: string }) { return } dispatch({ type: "auth.pending" }) - const start = Date.now() - await serverSDK.client.provider.oauth - .authorize( - { - providerID: props.provider, - method: index, - inputs, - }, - { throwOnError: true }, - ) + await serverSDK() + .api.integration.oauth.connect({ + integrationID: props.provider, + methodID: method.id, + inputs: inputs ?? {}, + location: location(), + }) .then((x) => { if (!alive.value) return - const elapsed = Date.now() - start - const delay = 1000 - elapsed - - if (delay > 0) { - if (timer.current !== undefined) clearTimeout(timer.current) - timer.current = setTimeout(() => { - timer.current = undefined - if (!alive.value) return - dispatch({ type: "auth.complete", authorization: x.data! }) - }, delay) - return - } - dispatch({ type: "auth.complete", authorization: x.data! }) + dispatch({ type: "auth.complete", authorization: x.data }) }) .catch((e) => { if (!alive.value) return @@ -190,16 +569,15 @@ export function DialogConnectProvider(props: { provider: string }) { } } - function OAuthPromptsView() { + function AuthPromptsView() { const [formStore, setFormStore] = createStore({ value: {} as Record, index: 0, }) - const prompts = createMemo>(() => { + const prompts = createMemo(() => { const value = method() - if (value?.type !== "oauth") return [] - return value.prompts ?? [] + return value?.type === "oauth" ? (value.prompts ?? []) : [] }) const matches = (prompt: NonNullable[number]>, value: Record) => { if (!prompt.when) return true @@ -331,7 +709,9 @@ export function DialogConnectProvider(props: { provider: string }) { }) async function complete() { - await serverSDK.client.global.dispose() + await serverSync() + .refreshProviders() + .catch(() => undefined) dialog.close() showToast({ variant: "success", @@ -342,22 +722,47 @@ export function DialogConnectProvider(props: { provider: string }) { } function goBack() { - if (methods().length === 1) { - all() - return - } - if (store.authorization) { + if (methods().length > 1 && store.methodIndex !== undefined) { dispatch({ type: "method.reset" }) return } - if (store.methodIndex !== undefined) { - dispatch({ type: "method.reset" }) - return - } - all() + props.onBack() } + props.setBack(goBack) + function MethodSelection() { + if (newLayout()) + return ( +
+
+ {language.t("provider.connect.selectMethod", { provider: provider().name })} +
+
+ + {(item, index) => { + const details = () => methodDetails(item) + return ( + + ) + }} + +
+
+ ) + return ( <>
@@ -370,7 +775,7 @@ export function DialogConnectProvider(props: { provider: string }) { listRef = ref }} items={methods} - key={(m) => m?.label} + key={(m) => m?.label ?? m?.type} onSelect={async (selected, index) => { if (!selected) return void selectMethod(index) @@ -391,11 +796,18 @@ export function DialogConnectProvider(props: { provider: string }) { } function ApiAuthView() { + let apiKey: HTMLInputElement | undefined + const errorID = createUniqueId() const [formStore, setFormStore] = createStore({ value: "", error: undefined as string | undefined, }) + onMount(() => { + if (!newLayout()) return + apiKey?.focus({ preventScroll: true }) + }) + async function handleSubmit(e: SubmitEvent) { e.preventDefault() @@ -409,16 +821,67 @@ export function DialogConnectProvider(props: { provider: string }) { } setFormStore("error", undefined) - await serverSDK.client.auth.set({ - providerID: props.provider, - auth: { - type: "api", - key: apiKey, - }, + await serverSDK().api.integration.connect.key({ + integrationID: props.provider, + location: location(), + key: apiKey, }) await complete() } + if (newLayout()) + return ( +
+ +
+
{language.t("provider.connect.opencodeZen.line1")}
+
{language.t("provider.connect.opencodeZen.line2")}
+
+ {language.t("provider.connect.opencodeZen.visit.prefix")} + + {language.t("provider.connect.opencodeZen.visit.link")} + + {language.t("provider.connect.opencodeZen.visit.suffix")} +
+
+
+
+ + + {(error) => ( + + )} + + + {language.t("common.continue")} + +
+
+ ) + return (
@@ -428,9 +891,9 @@ export function DialogConnectProvider(props: { provider: string }) {
{language.t("provider.connect.opencodeZen.line2")}
{language.t("provider.connect.opencodeZen.visit.prefix")} - + {language.t("provider.connect.opencodeZen.visit.link")} - + {language.t("provider.connect.opencodeZen.visit.suffix")}
@@ -443,7 +906,8 @@ export function DialogConnectProvider(props: { provider: string }) {
{ + if (!newLayout()) return + codeInput?.focus({ preventScroll: true }) + }) + async function handleSubmit(e: SubmitEvent) { e.preventDefault() @@ -480,13 +951,14 @@ export function DialogConnectProvider(props: { provider: string }) { } setFormStore("error", undefined) - const result = await serverSDK.client.provider.oauth - .callback({ - providerID: props.provider, - method: store.methodIndex, + const result = await serverSDK() + .api.integration.oauth.complete({ + integrationID: props.provider, + attemptID: store.authorization!.attemptID, + location: location(), code, }) - .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) + .then(() => ({ ok: true as const })) .catch((error) => ({ ok: false as const, error })) if (result.ok) { await complete() @@ -495,16 +967,59 @@ export function DialogConnectProvider(props: { provider: string }) { setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid"))) } + if (newLayout()) + return ( +
+
+ {language.t("provider.connect.oauth.code.visit.prefix")} + + {language.t("provider.connect.oauth.code.visit.link")} + + {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })} +
+ + + + {(error) => ( + + )} + + + {language.t("common.continue")} + + +
+ ) + return (
{language.t("provider.connect.oauth.code.visit.prefix")} - {language.t("provider.connect.oauth.code.visit.link")} + + {language.t("provider.connect.oauth.code.visit.link")} + {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
{ - void (async () => { - const result = await serverSDK.client.provider.oauth - .callback({ - providerID: props.provider, - method: store.methodIndex, + const poll = async () => { + const authorization = store.authorization + if (!authorization || !alive.value) return + const result = await serverSDK() + .api.integration.oauth.status({ + integrationID: props.provider, + attemptID: authorization.attemptID, + location: location(), }) - .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) + .then((value) => ({ ok: true as const, status: value.data })) .catch((error) => ({ ok: false as const, error })) - if (!alive.value) return - if (!result.ok) { - const message = formatError(result.error, language.t("common.requestFailed")) - dispatch({ type: "auth.error", error: message }) + dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) }) return } - - await complete() - })() + if (result.status.status === "complete") { + await complete() + return + } + if (result.status.status === "failed") { + dispatch({ type: "auth.error", error: result.status.message }) + return + } + if (result.status.status === "expired") { + dispatch({ type: "auth.error", error: language.t("common.requestFailed") }) + return + } + timer.current = setTimeout(poll, 1_000) + } + void poll() }) return (
{language.t("provider.connect.oauth.auto.visit.prefix")} - {language.t("provider.connect.oauth.auto.visit.link")} + + {language.t("provider.connect.oauth.auto.visit.link")} + {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
+
+ - } - > -
-
- -
- - - {language.t("provider.connect.title.anthropicProMax")} - - {language.t("provider.connect.title", { provider: provider().name })} - -
+
+ + + {language.t("provider.connect.title.anthropicProMax")} + + {language.t("provider.connect.title", { provider: provider().name })} +
-
-
- - -
-
- - {language.t("provider.connect.status.inProgress")} -
+
+
+
+ + +
+
+ + {language.t("provider.connect.status.inProgress")}
- - - - - -
-
- - {language.t("provider.connect.status.inProgress")} -
+
+
+ + + + +
+
+ + {language.t("provider.connect.status.inProgress")}
- - - - - -
-
- - {language.t("provider.connect.status.failed", { error: store.error ?? "" })} -
+
+
+ + + + +
+
+ + {language.t("provider.connect.status.failed", { error: store.error ?? "" })}
- - - - - - - - - - - - - - - -
+
+
+ + + + + + + + + + + + + +
- +
) } diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 38690bb24106..e3c11c335ed5 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -8,18 +8,39 @@ import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@/utils/toast" import { batch, For } from "solid-js" import { createStore, produce } from "solid-js/store" -import { Link } from "@/components/link" +import { ExternalLink } from "@/components/external-link" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form" -import { DialogSelectProvider } from "./dialog-select-provider" type Props = { - back?: "providers" | "close" + onBack: () => void } export function DialogCustomProvider(props: Props) { + const language = useLanguage() + + return ( + + } + transition + > + + + ) +} + +export function CustomProviderForm(props: { autofocus?: boolean } = {}) { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() @@ -35,14 +56,6 @@ export function DialogCustomProvider(props: Props) { err: {}, }) - const goBack = () => { - if (props.back === "close") { - dialog.close() - return - } - dialog.show(() => ) - } - const addModel = () => { setForm( "models", @@ -105,8 +118,8 @@ export function DialogCustomProvider(props: Props) { const output = validateCustomProvider({ form, t: language.t, - disabledProviders: serverSync.data.config.disabled_providers ?? [], - existingProviderIDs: new Set(serverSync.data.provider.all.keys()), + disabledProviders: serverSync().data.config.disabled_providers ?? [], + existingProviderIDs: new Set(serverSync().data.provider.all.keys()), }) batch(() => { setForm("err", output.err) @@ -118,11 +131,12 @@ export function DialogCustomProvider(props: Props) { const saveMutation = useMutation(() => ({ mutationFn: async (result: NonNullable>) => { - const disabledProviders = serverSync.data.config.disabled_providers ?? [] + if ((await serverSDK().protocol) !== "v1") throw new Error(language.t("provider.custom.unavailable")) + const disabledProviders = serverSync().data.config.disabled_providers ?? [] const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) if (result.key) { - await serverSDK.client.auth.set({ + await serverSDK().client.auth.set({ providerID: result.providerID, auth: { type: "api", @@ -131,7 +145,7 @@ export function DialogCustomProvider(props: Props) { }) } - await serverSync.updateConfig({ + await serverSync().updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled, }) @@ -162,168 +176,155 @@ export function DialogCustomProvider(props: Props) { } return ( - - } - transition - > -
-
- -
{language.t("provider.custom.title")}
-
+
+
+ +
{language.t("provider.custom.title")}
+
- -

- {language.t("provider.custom.description.prefix")} - - {language.t("provider.custom.description.link")} - - {language.t("provider.custom.description.suffix")} -

+ +

+ {language.t("provider.custom.description.prefix")} + + {language.t("provider.custom.description.link")} + + {language.t("provider.custom.description.suffix")} +

-
- setField("providerID", v)} - validationState={form.err.providerID ? "invalid" : undefined} - error={form.err.providerID} - /> - setField("name", v)} - validationState={form.err.name ? "invalid" : undefined} - error={form.err.name} - /> - setField("baseURL", v)} - validationState={form.err.baseURL ? "invalid" : undefined} - error={form.err.baseURL} - /> - setField("apiKey", v)} - /> -
+
+ setField("providerID", v)} + validationState={form.err.providerID ? "invalid" : undefined} + error={form.err.providerID} + /> + setField("name", v)} + validationState={form.err.name ? "invalid" : undefined} + error={form.err.name} + /> + setField("baseURL", v)} + validationState={form.err.baseURL ? "invalid" : undefined} + error={form.err.baseURL} + /> + setField("apiKey", v)} + /> +
-
- - - {(m, i) => ( -
-
- setModel(i(), "id", v)} - validationState={m.err.id ? "invalid" : undefined} - error={m.err.id} - /> -
-
- setModel(i(), "name", v)} - validationState={m.err.name ? "invalid" : undefined} - error={m.err.name} - /> -
- removeModel(i())} - disabled={form.models.length <= 1} - aria-label={language.t("provider.custom.models.remove")} +
+ + + {(m, i) => ( +
+
+ setModel(i(), "id", v)} + validationState={m.err.id ? "invalid" : undefined} + error={m.err.id} />
- )} - - -
- -
- - - {(h, i) => ( -
-
- setHeader(i(), "key", v)} - validationState={h.err.key ? "invalid" : undefined} - error={h.err.key} - /> -
-
- setHeader(i(), "value", v)} - validationState={h.err.value ? "invalid" : undefined} - error={h.err.value} - /> -
- removeHeader(i())} - disabled={form.headers.length <= 1} - aria-label={language.t("provider.custom.headers.remove")} +
+ setModel(i(), "name", v)} + validationState={m.err.name ? "invalid" : undefined} + error={m.err.name} />
- )} - - -
+ removeModel(i())} + disabled={form.models.length <= 1} + aria-label={language.t("provider.custom.models.remove")} + /> +
+ )} +
+ +
- - -
-
+
+ + + +
) } diff --git a/packages/app/src/components/dialog-edit-project-v2.tsx b/packages/app/src/components/dialog-edit-project-v2.tsx new file mode 100644 index 000000000000..dc9714aa0625 --- /dev/null +++ b/packages/app/src/components/dialog-edit-project-v2.tsx @@ -0,0 +1,156 @@ +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2" +import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" +import { Field } from "@opencode-ai/ui/v2/field-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2" +import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { For, Show } from "solid-js" +import { useLanguage } from "@/context/language" +import { getProjectAvatarVariant, type LocalProject } from "@/context/layout" +import { ServerConnection } from "@/context/server" +import { getProjectAvatarSource } from "@/pages/layout/helpers" +import { createEditProjectModel } from "./edit-project" + +export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) { + const language = useLanguage() + const model = createEditProjectModel(props) + + return ( + +
+ + {language.t("dialog.project.edit.title")} + + + + + {language.t("dialog.project.edit.name")} + model.setStore("name", event.currentTarget.value)} + /> + + +
+
+ {language.t("dialog.project.edit.icon")} +
+
+ + { + model.setIconInput(element) + }} + type="file" + accept="image/*" + class="hidden" + onChange={model.inputChange} + /> +
+ {language.t("dialog.project.edit.icon.hint")} + {language.t("dialog.project.edit.icon.recommended")} +
+
+
+ + +
+
+ {language.t("dialog.project.edit.color")} +
+
+ + {(color) => ( + + )} + +
+
+
+ + + {language.t("dialog.project.edit.worktree.startup")} + {language.t("dialog.project.edit.worktree.startup.description")} + model.setStore("startup", event.currentTarget.value)} + /> + +
+ + + {language.t("common.cancel")} + + + {model.save.isPending ? language.t("common.saving") : language.t("common.save")} + + + +
+ ) +} diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index 29ee4dfeabdd..86a9630359ba 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -1,123 +1,32 @@ import { Button } from "@opencode-ai/ui/button" -import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { TextField } from "@opencode-ai/ui/text-field" -import { useMutation } from "@tanstack/solid-query" import { Icon } from "@opencode-ai/ui/icon" -import { createMemo, For, Show } from "solid-js" -import { createStore } from "solid-js/store" +import { For, Show } from "solid-js" import { type LocalProject, getAvatarColors } from "@/context/layout" -import { getFilename } from "@opencode-ai/core/util/path" import { Avatar } from "@opencode-ai/ui/avatar" import { useLanguage } from "@/context/language" import { getProjectAvatarSource } from "@/pages/layout/helpers" import { ServerConnection } from "@/context/server" -import { useGlobal } from "@/context/global" +import { createEditProjectModel } from "./edit-project" const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) { - const dialog = useDialog() - const global = useGlobal() const language = useLanguage() - const serverCtx = createMemo(() => global.createServerCtx(props.server)) - const serverSDK = () => serverCtx().sdk - const serverSync = () => serverCtx().sync - - const folderName = createMemo(() => getFilename(props.project.worktree)) - const defaultName = createMemo(() => props.project.name || folderName()) - - const [store, setStore] = createStore({ - name: defaultName(), - color: props.project.icon?.color, - iconOverride: props.project.icon?.override, - startup: props.project.commands?.start ?? "", - dragOver: false, - iconHover: false, - }) - - let iconInput: HTMLInputElement | undefined - - function handleFileSelect(file: File) { - if (!file.type.startsWith("image/")) return - const reader = new FileReader() - reader.onload = (e) => { - setStore("iconOverride", e.target?.result as string) - setStore("iconHover", false) - } - reader.readAsDataURL(file) - } - - function handleDrop(e: DragEvent) { - e.preventDefault() - setStore("dragOver", false) - const file = e.dataTransfer?.files[0] - if (file) handleFileSelect(file) - } - - function handleDragOver(e: DragEvent) { - e.preventDefault() - setStore("dragOver", true) - } - - function handleDragLeave() { - setStore("dragOver", false) - } - - function handleInputChange(e: Event) { - const input = e.target as HTMLInputElement - const file = input.files?.[0] - if (file) handleFileSelect(file) - } - - function clearIcon() { - setStore("iconOverride", "") - } - - const saveMutation = useMutation(() => ({ - mutationFn: async () => { - const name = store.name.trim() === folderName() ? "" : store.name.trim() - const start = store.startup.trim() - - if (props.project.id && props.project.id !== "global") { - await serverSDK().client.project.update({ - projectID: props.project.id, - directory: props.project.worktree, - name, - icon: { color: store.color || "", override: store.iconOverride || "" }, - commands: { start }, - }) - serverSync().project.icon(props.project.worktree, store.iconOverride || undefined) - dialog.close() - return - } - - serverSync().project.meta(props.project.worktree, { - name, - icon: { color: store.color || undefined, override: store.iconOverride || undefined }, - commands: { start: start || undefined }, - }) - dialog.close() - }, - })) - - function handleSubmit(e: SubmitEvent) { - e.preventDefault() - if (saveMutation.isPending) return - saveMutation.mutate() - } + const model = createEditProjectModel(props) return ( -
+
setStore("name", v)} + placeholder={model.folderName()} + value={model.store.name} + onChange={(v) => model.setStore("name", v)} />
@@ -125,38 +34,32 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
setStore("iconHover", true)} - onMouseLeave={() => setStore("iconHover", false)} + onMouseEnter={() => model.setStore("iconHover", true)} + onMouseLeave={() => model.setStore("iconHover", false)} >
{ - if (store.iconOverride && store.iconHover) { - clearIcon() - } else { - iconInput?.click() - } + "border-text-interactive-base bg-surface-info-base/20": model.store.dragOver, + "border-border-base hover:border-border-strong": !model.store.dragOver, + "overflow-hidden": !!model.store.iconOverride, }} + onDrop={model.drop} + onDragOver={model.dragOver} + onDragLeave={model.dragLeave} + onClick={model.iconClick} >
@@ -174,8 +77,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
@@ -183,8 +86,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
@@ -193,12 +96,12 @@ export function DialogEditProject(props: { project: LocalProject; server: Server { - iconInput = el + model.setIconInput(el) }} type="file" accept="image/*" class="hidden" - onChange={handleInputChange} + onChange={model.inputChange} />
{language.t("dialog.project.edit.icon.hint")} @@ -207,7 +110,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
- +
@@ -216,21 +119,21 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
- -
diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index d0d105e078e9..5187d980ea26 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -35,13 +35,13 @@ export const DialogFork: Component = () => { const sessionID = params.id if (!sessionID) return [] - const msgs = sync.data.message[sessionID] ?? [] + const msgs = sync().data.message[sessionID] ?? [] const result: ForkableMessage[] = [] for (const message of msgs) { if (message.role !== "user") continue - const parts = sync.data.part[message.id] ?? [] + const parts = sync().data.part[message.id] ?? [] const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored) if (!textPart) continue @@ -61,23 +61,19 @@ export const DialogFork: Component = () => { const sessionID = params.id if (!sessionID) return - const parts = sync.data.part[item.id] ?? [] + const parts = sync().data.part[item.id] ?? [] const restored = extractPromptFromParts(parts, { - directory: sdk.directory, + directory: sdk().directory, attachmentName: language.t("common.attachment"), }) - const dir = base64Encode(sdk.directory) + const dir = base64Encode(sdk().directory) - sdk.client.session - .fork({ sessionID, messageID: item.id }) + sdk() + .api.session.fork({ sessionID, messageID: item.id }) .then((forked) => { - if (!forked.data) { - showToast({ title: language.t("common.requestFailed") }) - return - } dialog.close() - prompt.set(restored, undefined, { dir, id: forked.data.id }) - navigate(`/${dir}/session/${forked.data.id}`) + prompt.set(restored, undefined, { dir, id: forked.id }) + navigate(`/${dir}/session/${forked.id}`) }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) diff --git a/packages/app/src/components/dialog-manage-models.tsx b/packages/app/src/components/dialog-manage-models.tsx index b46034bbad96..d6c5e9918670 100644 --- a/packages/app/src/components/dialog-manage-models.tsx +++ b/packages/app/src/components/dialog-manage-models.tsx @@ -3,20 +3,35 @@ import { List } from "@opencode-ai/ui/list" import { Switch } from "@opencode-ai/ui/switch" import { Tooltip } from "@opencode-ai/ui/tooltip" import { Button } from "@opencode-ai/ui/button" -import type { Component } from "solid-js" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Dialog as DialogV2, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { Switch as SwitchV2 } from "@opencode-ai/ui/v2/switch-v2" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { useFilteredList } from "@opencode-ai/ui/hooks" +import { For, Show, type Component } from "solid-js" import { useLocal } from "@/context/local" import { popularProviders } from "@/hooks/use-providers" import { useLanguage } from "@/context/language" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { DialogSelectProvider } from "./dialog-select-provider" +import { DialogConnectProvider } from "./dialog-connect-provider" +import { decode64 } from "@/utils/base64" +import { SettingsListV2 } from "./settings-v2/parts/list" +import { SettingsRowV2 } from "./settings-v2/parts/row" +import "./settings-v2/settings-v2.css" + +type ModelItem = ReturnType["model"]["list"]>[number] export const DialogManageModels: Component = () => { const local = useLocal() const language = useLanguage() const dialog = useDialog() + const directory = () => decode64(local.slug()) const handleConnectProvider = () => { - dialog.show(() => ) + void dialog.show(() => ) } const providerRank = (id: string) => popularProviders.indexOf(id) const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) @@ -100,3 +115,151 @@ export const DialogManageModels: Component = () => {
) } + +export const DialogManageModelsV2: Component = () => { + const local = useLocal() + const language = useLanguage() + const dialog = useDialog() + const directory = () => decode64(local.slug()) + + const handleConnectProvider = () => { + void dialog.show(() => ) + } + const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) + const providerVisible = (providerID: string) => + providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id })) + const setProviderVisibility = (providerID: string, checked: boolean) => { + providerList(providerID).forEach((x) => { + local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked) + }) + } + const setModelVisibility = (item: ModelItem, checked: boolean) => { + local.model.setVisibility({ modelID: item.id, providerID: item.provider.id }, checked) + } + const list = useFilteredList({ + items: () => local.model.list(), + key: (x) => `${x.provider.id}:${x.id}`, + filterKeys: ["provider.name", "name", "id"], + sortBy: (a, b) => a.name.localeCompare(b.name), + groupBy: (x) => x.provider.id, + sortGroupsBy: (a, b) => { + const aRank = popularProviders.indexOf(a.category) + const bRank = popularProviders.indexOf(b.category) + const aPopular = aRank >= 0 + const bPopular = bRank >= 0 + if (aPopular && !bPopular) return -1 + if (!aPopular && bPopular) return 1 + return aRank - bRank + }, + }) + + return ( + + + + + {language.t("command.provider.connect")} + + + +
+
+ list.onInput(event.currentTarget.value)} + placeholder={language.t("dialog.model.search.placeholder")} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + autofocus + aria-label={language.t("dialog.model.search.placeholder")} + /> + + } + onClick={() => list.clear()} + aria-label={language.t("common.clear")} + /> + +
+
+
+
+ + {language.t("common.loading")} + {language.t("common.loading.ellipsis")} +
+ } + > + 0} + fallback={ +
+ {language.t("dialog.model.empty")} + + "{list.filter()}" + +
+ } + > + + {(group) => ( +
+
+
+ +

{group.items[0].provider.name}

+
+
+ setProviderVisibility(group.category, checked)} + hideLabel + > + {group.items[0].provider.name} + +
+
+ + + {(item) => ( + +
+ setModelVisibility(item, checked)} + hideLabel + > + {item.name} + +
+
+ )} +
+
+
+ )} +
+
+ +
+
+ + + ) +} diff --git a/packages/app/src/components/dialog-select-directory-v2.css b/packages/app/src/components/dialog-select-directory-v2.css new file mode 100644 index 000000000000..e1d22f19c681 --- /dev/null +++ b/packages/app/src/components/dialog-select-directory-v2.css @@ -0,0 +1,107 @@ +.directory-picker-v2-body { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: 12px; + padding: 2px 16px 0; +} + +.directory-picker-v2-path { + position: relative; + z-index: 10; + display: flex; + gap: 8px; +} + +.directory-picker-v2-actions { + display: flex; + flex-shrink: 0; + gap: 2px; +} + +.directory-picker-v2-suggestions { + position: absolute; + z-index: 20; + top: 36px; + right: 0; + left: 0; + display: flex; + flex-direction: column; + padding: 4px; + border: 1px solid var(--v2-border-border-base); + border-radius: 6px; + background: var(--v2-background-bg-layer-02); + box-shadow: var(--v2-elevation-overlay); +} + +.directory-picker-v2-suggestions button { + overflow: hidden; + padding: 6px 8px; + border-radius: 4px; + color: var(--v2-text-text-muted); + font-size: 12px; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.directory-picker-v2-suggestions button:hover, +.directory-picker-v2-suggestions button[data-active] { + color: var(--v2-text-text-base); + background: var(--v2-overlay-simple-overlay-hover); +} + +.directory-picker-v2-browser { + position: relative; + z-index: 0; + isolation: isolate; + min-height: 0; + flex: 1; + overflow: auto; + border: 1px solid var(--v2-border-border-base); + border-radius: 6px; + background: transparent; +} + +.directory-picker-v2-tree { + display: block; + width: 100%; + height: 100%; + --trees-bg-override: transparent; + --trees-fg-override: var(--v2-text-text-base); + --trees-fg-muted-override: var(--v2-text-text-muted); + --trees-bg-muted-override: transparent; + --trees-selected-bg-override: transparent; + --trees-selected-fg-override: var(--v2-text-text-base); + --trees-selected-focused-border-color-override: transparent; + --trees-focus-ring-color-override: transparent; + --trees-focus-ring-width-override: 0px; + --trees-focus-ring-offset-override: 0px; + --trees-border-color-override: var(--v2-border-border-base); + --trees-font-family-override: var(--font-family-sans); + --trees-font-size-override: 12px; + --trees-item-height: 24px; + --trees-border-radius-override: 4px; +} + +.directory-picker-v2-state { + position: absolute; + z-index: 1; + inset: 0; + display: grid; + place-items: center; + color: var(--v2-text-text-muted); + font-size: 12px; + pointer-events: none; +} + +.directory-picker-v2-selection { + overflow: hidden; + flex-shrink: 0; + color: var(--v2-text-text-muted); + font-size: 12px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/packages/app/src/components/dialog-select-directory-v2.tsx b/packages/app/src/components/dialog-select-directory-v2.tsx new file mode 100644 index 000000000000..f3376ad35f26 --- /dev/null +++ b/packages/app/src/components/dialog-select-directory-v2.tsx @@ -0,0 +1,386 @@ +import "@pierre/trees/web-components" +import { FileTree } from "@pierre/trees" +import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js" +import { useGlobal } from "@/context/global" +import { useLanguage } from "@/context/language" +import { ServerConnection } from "@/context/server" +import type { Path } from "@opencode-ai/sdk/v2/client" +import { + absoluteTreePath, + activeTreeNavigation, + advanceTreePreload, + nextSuggestionIndex, + nextTreeScrollTop, + pickerFileSearchQuery, + pickerAbsoluteInput, + pickerMode, + preloadTreeDirectories, + cleanPickerInput, + createPriorityTaskQueue, + createDirectorySearch, + currentPickerSuggestions, + displayPickerPath, + pickerParent, + pickerRoot, +} from "./directory-picker-domain" +import "./dialog-select-directory-v2.css" +import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" +import { getFilename } from "@opencode-ai/core/util/path" + +interface DialogSelectDirectoryV2Props { + title?: string + multiple?: boolean + onSelect: (result: string | string[] | null) => void + server: ServerConnection.Any + mode?: "directory" | "file" + start?: string +} + +export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { + const global = useGlobal() + const { sync, sdk } = global.ensureServerCtx(props.server) + const dialog = useDialog() + const language = useLanguage() + const policy = pickerMode(props.mode ?? "directory", props.start) + const action = { + file: language.t("dialog.directory.action.selectFile"), + directory: language.t("dialog.directory.action.selectFolder"), + } + const [root, setRoot] = createSignal("") + const [input, setInput] = createSignal("") + const [selected, setSelected] = createSignal("") + const [suggestionsOpen, setSuggestionsOpen] = createSignal(false) + const [activeSuggestion, setActiveSuggestion] = createSignal(-1) + const [loading, setLoading] = createSignal(false) + const [error, setError] = createSignal(false) + const [rootValid, setRootValid] = createSignal(false) + const listings = new Map | undefined>>() + const loads = createPriorityTaskQueue | undefined>(3) + const advanced = new Set() + let tree: FileTree | undefined + let container: HTMLDivElement | undefined + let pathArea: HTMLDivElement | undefined + let navigation = 0 + + const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) + const [fallbackPath] = createResource( + () => (missingBase() ? true : undefined), + async (): Promise => { + if ((await sdk.protocol) !== "v1") return + return sdk.client.path + .get() + .then((result) => result.data) + .catch(() => undefined) + }, + { initialValue: undefined }, + ) + const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") + const start = createMemo( + () => + props.start || + sync.data.path.home || + sync.data.path.directory || + fallbackPath()?.home || + fallbackPath()?.directory, + ) + const search = createDirectorySearch({ sdk, home, base: () => root() || start() }) + const [suggestions] = createResource(input, async (value) => { + const cleaned = cleanPickerInput(value) + const typed = cleaned.replace(/\/+$/, "") + const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "") + if (!cleaned || (root() && typed === current)) return { query: value, items: [] } + const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const })) + if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) } + const base = pickerRoot(cleaned) || root() || start() + if (!base) return { query: value, items: directories.slice(0, 5) } + const files = await sdk.api.file + .find({ + location: { directory: base }, + query: pickerFileSearchQuery(base, value, home()), + type: "file", + limit: 20, + }) + .then((result) => result.data) + .catch(() => []) + const results = [ + ...directories, + ...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })), + ] + return { + query: value, + items: Array.from(new Map(results.map((result) => [result.absolute, result])).values()).slice(0, 8), + } + }) + const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input())) + + async function load(path: string, generation: number, eager = false) { + const key = path.replace(/\/+$/, "") + setError(false) + const absolute = absoluteTreePath(root(), key) + const existing = listings.get(key) + if (existing && !eager) loads.promote(`${generation}:${key}`) + const request = + existing ?? + loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => { + if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined) + return sdk.api.file + .list({ location: { directory: absolute } }) + .then((result) => + result.data.map((entry) => ({ + name: getFilename(entry.path.replace(/[\\/]+$/, "")), + type: entry.type, + })), + ) + .catch(() => undefined) + }) + listings.set(key, request) + const nodes = await request + if (!activeTreeNavigation(generation, navigation)) return false + if (!nodes) { + listings.delete(key) + if (!key) setError(true) + return false + } + tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item }))) + if (!eager && advanceTreePreload(advanced, key)) { + for (const directory of preloadTreeDirectories(key, nodes)) void load(directory, generation, true) + } + return true + } + + async function navigate(path: string) { + const value = policy.navigation(pickerAbsoluteInput(cleanPickerInput(path), home(), root() || start() || home())) + if (!value) return + const token = ++navigation + setLoading(true) + setRootValid(false) + setSelected("") + setSuggestionsOpen(false) + setActiveSuggestion(-1) + setRoot(value) + setInput(displayPickerPath(value, value, home())) + listings.clear() + advanced.clear() + tree?.resetPaths([]) + const valid = await load("", token) + if (!activeTreeNavigation(token, navigation)) return + setRootValid(valid) + setLoading(false) + } + + function complete() { + const items = currentSuggestions() + const match = items[activeSuggestion()] ?? items[0] + if (!match) return + const value = displayPickerPath(match.absolute, input(), home()) + setInput(match.type === "directory" && !value.endsWith("/") ? value + "/" : value) + if (match.type === "file") { + setSelected(policy.selection(root(), pickerFileSearchQuery(root(), match.absolute, home())) ?? "") + setSuggestionsOpen(false) + setActiveSuggestion(-1) + } + } + + function chooseSuggestion(suggestion: { absolute: string; type: "file" | "directory" }) { + if (suggestion.type === "directory") { + void navigate(suggestion.absolute) + return + } + setInput(displayPickerPath(suggestion.absolute, input(), home())) + setSelected(policy.selection(root(), pickerFileSearchQuery(root(), suggestion.absolute, home())) ?? "") + setSuggestionsOpen(false) + setActiveSuggestion(-1) + } + + function moveSuggestion(delta: -1 | 1) { + setSuggestionsOpen(true) + setActiveSuggestion((current) => nextSuggestionIndex(current, delta, currentSuggestions().length)) + } + + function activeSuggestionValue() { + const items = currentSuggestions() + return items[activeSuggestion()] ?? items[0] + } + + const keyActions: Partial void>> = { + ArrowDown: () => moveSuggestion(1), + ArrowUp: () => moveSuggestion(-1), + Enter: () => { + const suggestion = activeSuggestionValue() + if (suggestion) chooseSuggestion(suggestion) + if (!suggestion) void navigate(input()) + }, + Tab: complete, + } + + function handleInputKey(event: KeyboardEvent) { + const action = keyActions[event.key] + if (!action) return + if (event.key === "Tab" && event.shiftKey) return + event.preventDefault() + action() + } + + function resolve() { + const path = policy.result(root(), selected(), rootValid()) + if (!path) return + props.onSelect(props.multiple ? [path] : path) + dialog.close() + } + + onMount(() => { + const closeSuggestions = (event: PointerEvent) => { + if (pathArea?.contains(event.target as Node)) return + setSuggestionsOpen(false) + setActiveSuggestion(-1) + } + document.addEventListener("pointerdown", closeSuggestions) + onCleanup(() => document.removeEventListener("pointerdown", closeSuggestions)) + tree = new FileTree({ + paths: [], + flattenEmptyDirectories: false, + initialExpansion: "closed", + stickyFolders: true, + unsafeCSS: ` + button[data-type="item"] { + background: transparent !important; + box-shadow: none !important; + } + button[data-type="item"]:hover { + background: var(--v2-overlay-simple-overlay-hover) !important; + } + button[data-type="item"]:focus-visible { + outline: none !important; + box-shadow: none !important; + } + [data-file-tree-virtualized-scroll] { + overscroll-behavior: contain; + scrollbar-width: thin; + } + `, + onExpansionChange(change) { + if (change.expanded) void load(change.path, navigation) + }, + onSelectionChange(paths) { + const path = paths.at(-1) + setSelected(path ? (policy.selection(root(), path) ?? "") : "") + }, + }) + if (!container) return + tree.render({ containerWrapper: container }) + tree.getFileTreeContainer()?.classList.add("directory-picker-v2-tree") + }) + + createEffect(() => { + const path = start() + if (!path || root()) return + void navigate(path) + }) + + onCleanup(() => tree?.cleanUp()) + + return ( + + + {props.title ?? language.t("command.project.open")} + + + +
+ { + setInput(cleanPickerInput(event.currentTarget.value)) + setSelected("") + setSuggestionsOpen(true) + setActiveSuggestion(-1) + }} + role="combobox" + aria-autocomplete="list" + aria-expanded={suggestionsOpen()} + aria-controls="directory-picker-v2-suggestions" + aria-activedescendant={ + activeSuggestion() >= 0 ? `directory-picker-v2-suggestion-${activeSuggestion()}` : undefined + } + onKeyDown={handleInputKey} + /> +
+ void navigate(home())}> + ~ + + void navigate(pickerRoot(root()) || root())}> + {language.t("dialog.directory.root")} + + void navigate(pickerParent(root()))}> + {language.t("dialog.directory.parent")} + +
+ 0}> +
+ + {(suggestion, index) => ( + + )} + +
+
+
+
{ + const scroller = tree + ?.getFileTreeContainer() + ?.shadowRoot?.querySelector("[data-file-tree-virtualized-scroll]") + if (!scroller) return + const next = nextTreeScrollTop( + scroller.scrollTop, + event.deltaY, + scroller.scrollHeight, + scroller.clientHeight, + ) + if (next === scroller.scrollTop) return + event.preventDefault() + scroller.scrollTop = next + scroller.dispatchEvent(new Event("scroll")) + }} + > + +
{language.t("common.loading")}
+
+ +
{language.t("dialog.directory.readError")}
+
+
+
{policy.result(root(), selected(), rootValid())}
+
+ + dialog.close()}> + {language.t("common.cancel")} + + + {action[policy.action]} + + +
+ ) +} diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 4eb5ba4b588a..fc248d821e29 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -4,12 +4,12 @@ import { FileIcon } from "@opencode-ai/ui/file-icon" import { List } from "@opencode-ai/ui/list" import type { ListRef } from "@opencode-ai/ui/list" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" -import fuzzysort from "fuzzysort" import { createMemo, createResource, createSignal } from "solid-js" -import { ServerSDK } from "@/context/server-sdk" import { useLanguage } from "@/context/language" import { ServerConnection } from "@/context/server" import { useGlobal } from "@/context/global" +import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain" +import type { Path } from "@opencode-ai/sdk/v2/client" interface DialogSelectDirectoryProps { title?: string @@ -18,95 +18,17 @@ interface DialogSelectDirectoryProps { server: ServerConnection.Any } +const RECENT_PROJECT_LIMIT = 5 + type Row = { absolute: string search: string group: "recent" | "folders" } -function cleanInput(value: string) { - const first = (value ?? "").split(/\r?\n/)[0] ?? "" - return first.replace(/[\u0000-\u001F\u007F]/g, "").trim() -} - -function normalizePath(input: string) { - const v = input.replaceAll("\\", "/") - if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/") - return v.replace(/\/+/g, "/") -} - -function normalizeDriveRoot(input: string) { - const v = normalizePath(input) - if (/^[A-Za-z]:$/.test(v)) return v + "/" - return v -} - -function trimTrailing(input: string) { - const v = normalizeDriveRoot(input) - if (v === "/") return v - if (v === "//") return v - if (/^[A-Za-z]:\/$/.test(v)) return v - return v.replace(/\/+$/, "") -} - -function joinPath(base: string | undefined, rel: string) { - const b = trimTrailing(base ?? "") - const r = trimTrailing(rel).replace(/^\/+/, "") - if (!b) return r - if (!r) return b - if (b.endsWith("/")) return b + r - return b + "/" + r -} - -function rootOf(input: string) { - const v = normalizeDriveRoot(input) - if (v.startsWith("//")) return "//" - if (v.startsWith("/")) return "/" - if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3) - return "" -} - -function parentOf(input: string) { - const v = trimTrailing(input) - if (v === "/") return v - if (v === "//") return v - if (/^[A-Za-z]:\/$/.test(v)) return v - - const i = v.lastIndexOf("/") - if (i <= 0) return "/" - if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3) - return v.slice(0, i) -} - -function modeOf(input: string) { - const raw = normalizeDriveRoot(input.trim()) - if (!raw) return "relative" as const - if (raw.startsWith("~")) return "tilde" as const - if (rootOf(raw)) return "absolute" as const - return "relative" as const -} - -function tildeOf(absolute: string, home: string) { - const full = trimTrailing(absolute) - if (!home) return "" - - const hn = trimTrailing(home) - const lc = full.toLowerCase() - const hc = hn.toLowerCase() - if (lc === hc) return "~" - if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length) - return "" -} - -function displayPath(path: string, input: string, home: string) { - const full = trimTrailing(path) - if (modeOf(input) === "absolute") return full - return tildeOf(full, home) || full -} - function toRow(absolute: string, home: string, group: Row["group"]): Row { - const full = trimTrailing(absolute) - const tilde = tildeOf(full, home) + const full = displayPickerPath(absolute, "", "") + const tilde = displayPickerPath(full, "~", home) const withSlash = (value: string) => { if (!value) return "" if (value.endsWith("/")) return value @@ -128,136 +50,23 @@ function uniqueRows(rows: Row[]) { }) } -function useDirectorySearch(args: { sdk: ServerSDK; start: () => string | undefined; home: () => string }) { - const cache = new Map>>() - let current = 0 - - const scoped = (value: string) => { - const base = args.start() - if (!base) return - - const raw = normalizeDriveRoot(value) - if (!raw) return { directory: trimTrailing(base), path: "" } - - const h = args.home() - if (raw === "~") return { directory: trimTrailing(h || base), path: "" } - if (raw.startsWith("~/")) return { directory: trimTrailing(h || base), path: raw.slice(2) } - - const root = rootOf(raw) - if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) } - return { directory: trimTrailing(base), path: raw } - } - - const dirs = async (dir: string) => { - const key = trimTrailing(dir) - const existing = cache.get(key) - if (existing) return existing - - const request = args.sdk.client.file - .list({ directory: key, path: "" }) - .then((x) => x.data ?? []) - .catch(() => []) - .then((nodes) => - nodes - .filter((n) => n.type === "directory") - .map((n) => ({ - name: n.name, - absolute: trimTrailing(normalizeDriveRoot(n.absolute)), - })), - ) - - cache.set(key, request) - return request - } - - const match = async (dir: string, query: string, limit: number) => { - const items = await dirs(dir) - if (!query) return items.slice(0, limit).map((x) => x.absolute) - return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute) - } - - return async (filter: string) => { - const token = ++current - const active = () => token === current - - const value = cleanInput(filter) - const scopedInput = scoped(value) - if (!scopedInput) return [] as string[] - - const raw = normalizeDriveRoot(value) - const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/") - const query = normalizeDriveRoot(scopedInput.path) - - const find = () => - args.sdk.client.find - .files({ directory: scopedInput.directory, query, type: "directory", limit: 50 }) - .then((x) => x.data ?? []) - .catch(() => []) - - if (!isPath) { - const results = await find() - if (!active()) return [] - return results.map((rel) => joinPath(scopedInput.directory, rel)).slice(0, 50) - } - - const segments = query.replace(/^\/+/, "").split("/") - const head = segments.slice(0, segments.length - 1).filter((x) => x && x !== ".") - const tail = segments[segments.length - 1] ?? "" - - const cap = 12 - const branch = 4 - let paths = [scopedInput.directory] - for (const part of head) { - if (!active()) return [] - if (part === "..") { - paths = paths.map(parentOf) - continue - } - - const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat() - if (!active()) return [] - paths = Array.from(new Set(next)).slice(0, cap) - if (paths.length === 0) return [] as string[] - } - - const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat() - if (!active()) return [] - const deduped = Array.from(new Set(out)) - const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : "" - const expand = !raw.endsWith("/") - if (!expand || !tail) { - const items = base ? Array.from(new Set([base, ...deduped])) : deduped - return items.slice(0, 50) - } - - const needle = tail.toLowerCase() - const exact = deduped.filter((p) => getFilename(p).toLowerCase() === needle) - const target = exact[0] - if (!target) return deduped.slice(0, 50) - - const children = await match(target, "", 30) - if (!active()) return [] - const items = Array.from(new Set([...deduped, ...children])) - return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50) - } -} - export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const global = useGlobal() - const { sync, sdk, ...serverCtx } = global.createServerCtx(props.server) + const { sync, sdk, ...serverCtx } = global.ensureServerCtx(props.server) const dialog = useDialog() const language = useLanguage() const [filter, setFilter] = createSignal("") let list: ListRef | undefined - const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) + const missingHome = createMemo(() => !sync.data.path.home) const [fallbackPath] = createResource( - () => (missingBase() ? true : undefined), - async () => { + () => (missingHome() ? true : undefined), + async (): Promise => { + if ((await sdk.protocol) !== "v1") return return sdk.client.path .get() - .then((x) => x.data) + .then((result) => result.data) .catch(() => undefined) }, { initialValue: undefined }, @@ -268,10 +77,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { () => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory, ) - const directories = useDirectorySearch({ + const directories = createDirectorySearch({ sdk, home, - start, + base: start, }) const recentProjects = createMemo(() => { @@ -295,7 +104,6 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { return projects .map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index })) .sort((a, b) => b.at - a.at || a.index - b.index) - .slice(0, 5) .map(({ project }) => { const row = toRow(project.worktree, home(), "recent") const name = project.name || getFilename(project.worktree) @@ -309,7 +117,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const items = async (value: string) => { const results = await directories(value) const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders")) - return uniqueRows([...recentProjects(), ...directoryRows]) + // Cap the idle list only. Once a query narrows the results, every project stays searchable. + const recent = recentProjects() + const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT) + return uniqueRows([...visible, ...directoryRows]) } function resolve(absolute: string) { @@ -336,7 +147,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open") } ref={(r) => (list = r)} - onFilter={(value) => setFilter(cleanInput(value))} + onFilter={(value) => setFilter(cleanPickerInput(value))} onKeyEvent={(e, item) => { if (e.key !== "Tab") return if (e.shiftKey) return @@ -345,7 +156,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { e.preventDefault() e.stopPropagation() - const value = displayPath(item.absolute, filter(), home()) + const value = displayPickerPath(item.absolute, filter(), home()) list?.setFilter(value.endsWith("/") ? value : value + "/") }} onSelect={(path) => { @@ -354,10 +165,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { }} > {(item) => { - const path = displayPath(item.absolute, filter(), home()) + const path = displayPickerPath(item.absolute, filter(), home()) if (path === "~") { return ( -
+
@@ -369,7 +180,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { ) } return ( -
+
diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index b231d3dc86b9..905abe1e2dd3 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -1,325 +1,81 @@ -import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" import { Keybind } from "@opencode-ai/ui/keybind" import { List } from "@opencode-ai/ui/list" -import { base64Encode } from "@opencode-ai/core/util/encode" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" -import { useNavigate } from "@solidjs/router" -import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js" -import { formatKeybind, useCommand, type CommandOption } from "@/context/command" +import { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js" +import { formatKeybind } from "@/context/command" import { useServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" -import { useLayout } from "@/context/layout" -import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" import { useSessionLayout } from "@/pages/session/session-layout" -import { createSessionTabs } from "@/pages/session/helpers" import { decode64 } from "@/utils/base64" import { getRelativeTime } from "@/utils/time" - -type EntryType = "command" | "file" | "session" - -type Entry = { - id: string - type: EntryType - title: string - description?: string - keybind?: string - category: string - option?: CommandOption - path?: string - directory?: string - sessionID?: string - archived?: number - updated?: number -} - +import { + createCommandPaletteFileEntry, + createCommandPaletteFileOpener, + createCommandPaletteModel, + uniqueCommandPaletteEntries, + type CommandPaletteEntry, +} from "./command-palette" +import { DialogCommandPaletteV2 } from "./dialog-command-palette-v2" + +const DialogSelectFileV2 = lazy(() => + import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })), +) type DialogSelectFileMode = "all" | "files" -const ENTRY_LIMIT = 5 -const COMMON_COMMAND_IDS = [ - "session.new", - "workspace.new", - "session.previous", - "session.next", - "terminal.toggle", - "review.toggle", -] as const +export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { + const platform = usePlatform() + const settings = useSettings() + const filesOnly = () => props.mode === "files" -const uniqueEntries = (items: Entry[]) => { - const seen = new Set() - const out: Entry[] = [] - for (const item of items) { - if (seen.has(item.id)) continue - seen.add(item.id) - out.push(item) + if (!filesOnly() && settings.general.newLayoutDesigns()) { + return } - return out -} - -const createCommandEntry = (option: CommandOption, category: string): Entry => ({ - id: "command:" + option.id, - type: "command", - title: option.title, - description: option.description, - keybind: option.keybind, - category, - option, -}) - -const createFileEntry = (path: string, category: string): Entry => ({ - id: "file:" + path, - type: "file", - title: path, - category, - path, -}) - -const createSessionEntry = ( - input: { - directory: string - id: string - title: string - description: string - archived?: number - updated?: number - }, - category: string, -): Entry => ({ - id: `session:${input.directory}:${input.id}`, - type: "session", - title: input.title, - description: input.description, - category, - directory: input.directory, - sessionID: input.id, - archived: input.archived, - updated: input.updated, -}) - -function createCommandEntries(props: { - filesOnly: () => boolean - command: ReturnType - language: ReturnType -}) { - const allowed = createMemo(() => { - if (props.filesOnly()) return [] - return props.command.options.filter( - (option) => - !option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open", - ) - }) - - const list = createMemo(() => { - const category = props.language.t("palette.group.commands") - return allowed().map((option) => createCommandEntry(option, category)) - }) - - const picks = createMemo(() => { - const all = allowed() - const order = new Map(COMMON_COMMAND_IDS.map((id, index) => [id, index])) - const picked = all.filter((option) => order.has(option.id)) - const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT) - const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base - const category = props.language.t("palette.group.commands") - return sorted.map((option) => createCommandEntry(option, category)) - }) - - return { allowed, list, picks } -} - -function createFileEntries(props: { - file: ReturnType - tabs: () => ReturnType["tabs"]> - language: ReturnType -}) { - const tabState = createSessionTabs({ - tabs: props.tabs, - pathFromTab: props.file.pathFromTab, - normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab), - }) - const recent = createMemo(() => { - const all = tabState.openedTabs() - const active = tabState.activeFileTab() - const order = active ? [active, ...all.filter((item) => item !== active)] : all - const seen = new Set() - const category = props.language.t("palette.group.files") - const items: Entry[] = [] - - for (const item of order) { - const path = props.file.pathFromTab(item) - if (!path) continue - if (seen.has(path)) continue - seen.add(path) - items.push(createFileEntry(path, category)) - } - return items.slice(0, ENTRY_LIMIT) - }) - - const root = createMemo(() => { - const category = props.language.t("palette.group.files") - const nodes = props.file.tree.children("") - const paths = nodes - .filter((node) => node.type === "file") - .map((node) => node.path) - .sort((a, b) => a.localeCompare(b)) - return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category)) - }) - - return { recent, root } -} - -function createSessionEntries(props: { - workspaces: () => string[] - label: (directory: string) => string - serverSDK: ReturnType - language: ReturnType -}) { - const state: { - token: number - inflight: Promise | undefined - cached: Entry[] | undefined - } = { - token: 0, - inflight: undefined, - cached: undefined, + if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) { + return } - const sessions = (text: string) => { - const query = text.trim() - if (!query) { - state.token += 1 - state.inflight = undefined - state.cached = undefined - return [] as Entry[] - } - - if (state.cached) return state.cached - if (state.inflight) return state.inflight - - const current = state.token - const dirs = props.workspaces() - if (dirs.length === 0) return [] as Entry[] - - state.inflight = Promise.all( - dirs.map((directory) => { - const description = props.label(directory) - return props.serverSDK.client.session - .list({ directory, roots: true }) - .then((x) => - (x.data ?? []) - .filter((s) => !!s?.id) - .map((s) => ({ - id: s.id, - title: s.title ?? props.language.t("command.session.new"), - description, - directory, - archived: s.time?.archived, - updated: s.time?.updated, - })), - ) - .catch( - () => - [] as { - id: string - title: string - description: string - directory: string - archived?: number - updated?: number - }[], - ) - }), - ) - .then((results) => { - if (state.token !== current) return [] as Entry[] - const seen = new Set() - const category = props.language.t("command.category.session") - const next = results - .flat() - .filter((item) => { - const key = `${item.directory}:${item.id}` - if (seen.has(key)) return false - seen.add(key) - return true - }) - .map((item) => createSessionEntry(item, category)) - state.cached = next - return next - }) - .catch(() => [] as Entry[]) - .finally(() => { - state.inflight = undefined - }) - - return state.inflight - } - - return { sessions } + return } -export function DialogSelectFile(props: { - mode?: DialogSelectFileMode - onOpenFile?: (path: string) => void - onSelectFile?: (path: string) => void -}) { - const command = useCommand() +function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void }) { const language = useLanguage() - const layout = useLayout() - const file = useFile() - const dialog = useDialog() - const navigate = useNavigate() const serverSDK = useServerSDK() - const serverSync = useServerSync() - const { params, tabs, view } = useSessionLayout() - const filesOnly = () => props.mode === "files" - const state = { cleanup: undefined as (() => void) | void, committed: false } - const [grouped, setGrouped] = createSignal(false) - const commandEntries = createCommandEntries({ filesOnly, command, language }) - const fileEntries = createFileEntries({ file, tabs, language }) - + const { params } = useSessionLayout() const projectDirectory = createMemo(() => decode64(params.dir) ?? "") - const project = createMemo(() => { - const directory = projectDirectory() - if (!directory) return - return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) - }) - const workspaces = createMemo(() => { - const directory = projectDirectory() - const current = project() - if (!current) return directory ? [directory] : [] + const openFile = createCommandPaletteFileOpener(props.onOpenFile) - const dirs = [current.worktree, ...(current.sandboxes ?? [])] - if (directory && !dirs.includes(directory)) return [...dirs, directory] - return dirs - }) - const homedir = createMemo(() => serverSync.data.path.home) - const label = (directory: string) => { - const current = project() - const kind = - current && directory === current.worktree - ? language.t("workspace.type.local") - : language.t("workspace.type.sandbox") - const [store] = serverSync.child(directory, { bootstrap: false }) - const home = homedir() - const path = home ? directory.replace(home, "~") : directory - const name = store.vcs?.branch ?? getFilename(directory) - return `${kind} : ${name || path}` - } + return ( + { + if (typeof result !== "string") return + openFile(result) + }} + /> + ) +} - const { sessions } = createSessionEntries({ workspaces, label, serverSDK, language }) +function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?: (path: string) => void }) { + const palette = createCommandPaletteModel(props) + const [grouped, setGrouped] = createSignal(false) const items = async (text: string) => { const query = text.trim() setGrouped(query.length > 0) - if (!query && filesOnly()) { - const loaded = file.tree.state("")?.loaded - const pending = loaded ? Promise.resolve() : file.tree.list("") - const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()]) + if (!query && props.filesOnly()) { + const loaded = palette.file.tree.state("")?.loaded + const pending = loaded ? Promise.resolve() : palette.file.tree.list("") + const next = uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()]) if (loaded || next.length > 0) { void pending @@ -327,90 +83,46 @@ export function DialogSelectFile(props: { } await pending - return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()]) + return uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()]) } - if (!query) return [...commandEntries.picks(), ...fileEntries.recent()] + if (!query) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()] - if (filesOnly()) { - const files = await file.searchFiles(query) - const category = language.t("palette.group.files") - return files.map((path) => createFileEntry(path, category)) + if (props.filesOnly()) { + const files = await palette.file.searchFiles(query) + const category = palette.language.t("palette.group.files") + return files.map((path) => createCommandPaletteFileEntry(path, category)) } - const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))]) - const category = language.t("palette.group.files") - const entries = files.map((path) => createFileEntry(path, category)) - return [...commandEntries.list(), ...nextSessions, ...entries] + const [files, nextSessions] = await Promise.all([ + palette.file.searchFiles(query), + Promise.resolve(palette.sessions(query)), + ]) + const category = palette.language.t("palette.group.files") + const entries = files.map((path) => createCommandPaletteFileEntry(path, category)) + return [...palette.commandEntries(), ...nextSessions, ...entries] } - const handleMove = (item: Entry | undefined) => { - state.cleanup?.() - if (!item) return - if (item.type !== "command") return - state.cleanup = item.option?.onHighlight?.() - } - - const open = (path: string) => { - const value = file.tab(path) - void tabs().open(value) - void file.load(path) - if (!view().reviewPanel.opened()) view().reviewPanel.open() - layout.fileTree.setTab("all") - props.onOpenFile?.(path) - tabs().setActive(value) - } - - const handleSelect = (item: Entry | undefined) => { - if (!item) return - state.committed = true - state.cleanup = undefined - dialog.close() - - if (item.type === "command") { - item.option?.onSelect?.("palette") - return - } - - if (item.type === "session") { - if (!item.directory || !item.sessionID) return - navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`) - return - } - - if (!item.path) return - if (props.onSelectFile) { - props.onSelectFile(item.path) - return - } - open(item.path) - } - - onCleanup(() => { - if (state.committed) return - state.cleanup?.() - }) - return ( item.id} filterKeys={["title", "description", "category"]} skipFilter={(item) => item.type === "file"} groupBy={grouped() ? (item) => item.category : () => ""} - onMove={handleMove} - onSelect={handleSelect} + onMove={(item: CommandPaletteEntry | undefined) => palette.highlight(item)} + onSelect={(item: CommandPaletteEntry | undefined) => palette.select(item)} > {(item) => (
- {formatKeybind(item.keybind ?? "", language.t)} + {formatKeybind(item.keybind ?? "", palette.language.t)}
@@ -464,7 +176,7 @@ export function DialogSelectFile(props: {
- {getRelativeTime(new Date(item.updated!).toISOString(), language.t)} + {getRelativeTime(new Date(item.updated!).toISOString(), palette.language.t)}
diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index 176817bfd18e..4f1a3cd2392c 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -19,7 +19,7 @@ export const DialogSelectMcp: Component = () => { const language = useLanguage() const items = createMemo(() => - Object.entries(sync.data.mcp ?? {}) + Object.entries(sync().data.mcp ?? {}) .map(([name, status]) => ({ name, status: status.status })) .sort((a, b) => a.name.localeCompare(b.name)), ) @@ -43,12 +43,12 @@ export const DialogSelectMcp: Component = () => { filterKeys={["name", "status"]} sortBy={(a, b) => a.name.localeCompare(b.name)} onSelect={(x) => { - if (!x || toggle.isPending) return + if (!x || x.status === "pending" || toggle.isPending) return toggle.mutate(x.name) }} > {(i) => { - const mcpStatus = () => sync.data.mcp[i.name] + const mcpStatus = () => sync().data.mcp[i.name] const status = () => mcpStatus()?.status const statusLabel = () => { const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined @@ -76,7 +76,7 @@ export const DialogSelectMcp: Component = () => {
e.stopPropagation()}> { if (toggle.isPending) return toggle.mutate(i.name) diff --git a/packages/app/src/components/dialog-select-model-search.test.ts b/packages/app/src/components/dialog-select-model-search.test.ts new file mode 100644 index 000000000000..3ee800914042 --- /dev/null +++ b/packages/app/src/components/dialog-select-model-search.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test" +import { matchesModelSearch } from "./dialog-select-model-search" + +describe("matchesModelSearch", () => { + test("matches model names across separators", () => { + expect(matchesModelSearch("gpt 5", ["GPT-5.5"])).toBe(true) + expect(matchesModelSearch("gpt-5", ["GPT-5.5"])).toBe(true) + expect(matchesModelSearch("gpt5", ["GPT-5.5"])).toBe(true) + }) + + test("matches any searchable model field", () => { + expect(matchesModelSearch("open ai", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(true) + expect(matchesModelSearch("gpt 5", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(true) + }) + + test("does not match unrelated searches", () => { + expect(matchesModelSearch("claude", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(false) + }) +}) diff --git a/packages/app/src/components/dialog-select-model-search.ts b/packages/app/src/components/dialog-select-model-search.ts new file mode 100644 index 000000000000..901f3fc114a6 --- /dev/null +++ b/packages/app/src/components/dialog-select-model-search.ts @@ -0,0 +1,18 @@ +export const normalizeModelSearch = (value: string) => + value + .toLowerCase() + .replace(/[^\p{Letter}\p{Number}]+/gu, " ") + .trim() + .replace(/\s+/g, " ") + +export const compactModelSearch = (value: string) => normalizeModelSearch(value).replaceAll(" ", "") + +export const matchesModelSearch = (query: string, values: string[]) => { + const search = normalizeModelSearch(query) + if (!search) return true + + const compactSearch = compactModelSearch(query) + return values.some( + (value) => normalizeModelSearch(value).includes(search) || compactModelSearch(value).includes(compactSearch), + ) +} diff --git a/packages/app/src/components/dialog-select-model-unpaid-v2.stories.tsx b/packages/app/src/components/dialog-select-model-unpaid-v2.stories.tsx new file mode 100644 index 000000000000..bd4beb2ec9b1 --- /dev/null +++ b/packages/app/src/components/dialog-select-model-unpaid-v2.stories.tsx @@ -0,0 +1,55 @@ +// @ts-nocheck +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createSignal, onMount } from "solid-js" +import { DialogSelectModelUnpaidV2 } from "./dialog-select-model-unpaid-v2" + +const names = [ + "MiMo V2.5 Free", + "Nemotron 3 Ultra Free", + "Deepseek V4 Flash Free", + "North Mini Code Free", + "Hy3 Free", + "Big Pickle", +] + +function SelectModelWithoutProviders() { + const dialog = useDialog() + const models = names.map((name, index) => ({ + id: name.toLowerCase().replaceAll(" ", "-"), + name, + provider: { id: "opencode", name: "OpenCode" }, + cost: { input: 0, output: 0 }, + limit: { context: 128_000 }, + capabilities: { + reasoning: index !== 5, + input: { text: true, image: false, audio: false, video: false, pdf: false }, + }, + })) + const [current, setCurrent] = createSignal(models[2]) + const model = { + list: () => models, + current, + set(value) { + setCurrent(models.find((item) => item.id === value?.modelID)) + }, + } + const open = () => dialog.show(() => ) + + onMount(open) + + return ( + + ) +} + +export default { + title: "App/Dialogs/Select Model", + id: "app-dialog-select-model", +} + +export const WithoutProviders = { + render: () => , +} diff --git a/packages/app/src/components/dialog-select-model-unpaid-v2.tsx b/packages/app/src/components/dialog-select-model-unpaid-v2.tsx new file mode 100644 index 000000000000..2d0c3a7305c7 --- /dev/null +++ b/packages/app/src/components/dialog-select-model-unpaid-v2.tsx @@ -0,0 +1,176 @@ +import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { Tag } from "@opencode-ai/ui/v2/badge-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useTheme } from "@opencode-ai/ui/theme" +import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js" +import { useLocal } from "@/context/local" +import { useProviders } from "@/hooks/use-providers" +import { decode64 } from "@/utils/base64" +import { useLanguage } from "@/context/language" +import { ModelTooltip } from "./model-tooltip" + +type ModelState = ReturnType["model"] +const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"] +const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "") + +export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => { + const local = useLocal() + const model = props.model ?? local.model + const dialog = useDialog() + const theme = useTheme() + const directory = () => decode64(local.slug()) + const providers = useProviders(directory) + const language = useLanguage() + const modelKey = (item: ReturnType[number]) => `${item.provider.id}:${item.id}` + const currentKey = createMemo(() => { + const c = model.current() + return c ? `${c.provider.id}:${c.id}` : undefined + }) + const isFree = (item: ReturnType[number]) => + item.provider.id === "opencode" && (!item.cost || item.cost.input === 0) + const freeModels = createMemo(() => model.list().filter(isFree)) + + const openProviders = (provider?: string) => { + void import("./dialog-connect-provider").then((x) => { + const controller = x.useProviderConnectController() + controller.select(provider) + void dialog.show(() => ) + }) + } + + const selectModel = (item: ReturnType[number]) => { + model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true }) + dialog.close() + } + + // Focus starts on the dialog's close button, outside the list, so listen at the + // document level while the dialog is mounted instead of on the list container. + let listEl: HTMLDivElement | undefined + onMount(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return + if (!listEl) return + const buttons = Array.from(listEl.querySelectorAll("button")) + if (buttons.length === 0) return + const index = buttons.indexOf(document.activeElement as HTMLButtonElement) + const next = + index < 0 ? (e.key === "ArrowDown" ? 0 : buttons.length - 1) : index + (e.key === "ArrowDown" ? 1 : -1) + buttons[(next + buttons.length) % buttons.length]?.focus() + e.preventDefault() + } + document.addEventListener("keydown", handleKeyDown) + onCleanup(() => document.removeEventListener("keydown", handleKeyDown)) + }) + + return ( + + + {language.t("dialog.model.select.title")} + + +
+
+
+
+ {language.t("dialog.model.unpaid.freeModels.title")} +
+
+ + {(item) => ( + + } + > + + + )} + +
+ +
+
+
+
+ {language.t("dialog.model.unpaid.addMore.title")} +
+
+
+ featuredProviders.includes(provider.id)) + .sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))} + > + {(provider) => ( + + )} + + +
+
+
+
+
+
+ ) +} diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx index fae743bb81bd..4611a36c950e 100644 --- a/packages/app/src/components/dialog-select-model-unpaid.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid.tsx @@ -10,26 +10,28 @@ import { useLocal } from "@/context/local" import { popularProviders, useProviders } from "@/hooks/use-providers" import { ModelTooltip } from "./model-tooltip" import { useLanguage } from "@/context/language" +import { decode64 } from "@/utils/base64" type ModelState = ReturnType["model"] export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => { - const model = props.model ?? useLocal().model + const local = useLocal() + const model = props.model ?? local.model const dialog = useDialog() - const providers = useProviders() + const directory = () => decode64(local.slug()) + const providers = useProviders(directory) const language = useLanguage() - const connect = (provider: string) => { + const openProviders = (provider?: string) => { void import("./dialog-connect-provider").then((x) => { - dialog.show(() => ) + const controller = x.useProviderConnectController() + controller.select(provider) + void dialog.show(() => ) }) } - const all = () => { - void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) - }) - } + const connect = (provider: string) => openProviders(provider) + const all = () => openProviders() let listRef: ListRef | undefined const handleKeyDown = (e: KeyboardEvent) => { diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index ca2643c3b5ba..9066f72434e5 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -1,22 +1,48 @@ import { Popover as Kobalte } from "@kobalte/core/popover" -import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } from "solid-js" +import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js" import { createStore } from "solid-js/store" import { useLocal } from "@/context/local" import { useDialog } from "@opencode-ai/ui/context/dialog" import { popularProviders } from "@/hooks/use-providers" import { Button } from "@opencode-ai/ui/button" import { IconButton } from "@opencode-ai/ui/icon-button" +import { ScrollView } from "@opencode-ai/ui/scroll-view" import { Tag } from "@opencode-ai/ui/tag" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { Tooltip } from "@opencode-ai/ui/tooltip" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { ModelTooltip } from "./model-tooltip" import { useLanguage } from "@/context/language" +import { decode64 } from "@/utils/base64" +import { handleDocumentSearchKeydown } from "@/utils/search-keydown" +import { createMenuDismissController } from "@/utils/menu-dismiss-controller" +import { createEventListener } from "@solid-primitives/event-listener" +import { matchesModelSearch } from "./dialog-select-model-search" const isFree = (provider: string, cost: { input: number } | undefined) => provider === "opencode" && (!cost || cost.input === 0) type ModelState = ReturnType["model"] +type ModelItem = ReturnType[number] + +const modelKey = (model: ModelItem) => `${model.provider.id}:${model.id}` +const manageKey = "action:manage" + +const sortModelGroups = (a: { category: string; items: ModelItem[] }, b: { category: string; items: ModelItem[] }) => { + const aIndex = popularProviders.indexOf(a.category) + const bIndex = popularProviders.indexOf(b.category) + const aPopular = aIndex >= 0 + const bPopular = bIndex >= 0 + + if (aPopular && !bPopular) return -1 + if (!aPopular && bPopular) return 1 + if (aPopular && bPopular) return aIndex - bIndex + return a.items[0].provider.name.localeCompare(b.items[0].provider.name) +} const ModelList: Component<{ provider?: string @@ -58,6 +84,7 @@ const ModelList: Component<{ class="w-full" placement="right-start" gutter={12} + openDelay={0} value={} > {node} @@ -86,14 +113,13 @@ const ModelList: Component<{ } type ModelSelectorTriggerProps = Omit, "as" | "ref"> +type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element type Dismiss = "escape" | "outside" | "select" | "manage" | "provider" export function ModelSelectorPopover(props: { provider?: string model?: ModelState - children?: JSX.Element - triggerAs?: ValidComponent - triggerProps?: ModelSelectorTriggerProps + trigger: ModelSelectorTrigger onClose?: (cause: "escape" | "select") => void }) { const [store, setStore] = createStore<{ @@ -104,6 +130,8 @@ export function ModelSelectorPopover(props: { dismiss: null, }) const dialog = useDialog() + const local = useLocal() + const directory = () => decode64(local.slug()) const close = (dismiss: Dismiss) => { setStore("dismiss", dismiss) @@ -119,8 +147,8 @@ export function ModelSelectorPopover(props: { const handleConnectProvider = () => { close("provider") - void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) + void import("./dialog-connect-provider").then((x) => { + void dialog.show(() => ) }) } const language = useLanguage() @@ -136,9 +164,7 @@ export function ModelSelectorPopover(props: { placement="top-start" gutter={4} > - - {props.children} - + void +}) { + const dialog = useDialog() + const controller = createModelSelectorController({ + model: props.model, + provider: () => props.provider, + onSelect: () => props.onClose?.(), + }) + + return ( + { + void import("./dialog-manage-models").then((module) => { + void dialog.show(() => ) + }) + }} + onClose={() => props.onClose?.()} + /> + ) +} + +function createModelSelectorController(input: { + provider: () => string | undefined + model?: ModelState + onSelect: () => void +}) { + const model = input.model ?? useLocal().model + const allModels = createMemo(() => + model + .list() + .filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id })) + .filter((item) => (input.provider() ? item.provider.id === input.provider() : true)), + ) + + return { + models: (search: string) => { + const query = search.trim() + const filtered = query + ? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name])) + : allModels() + return [...filtered].sort((a, b) => a.name.localeCompare(b.name)) + }, + groups: (models: ModelItem[]) => { + const byProvider = new Map() + for (const item of models) { + byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item]) + } + return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups) + }, + current: () => { + const value = model.current() + return value ? modelKey(value) : undefined + }, + select: (item: ModelItem) => { + model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true }) + input.onSelect() + }, + } +} + +function ModelSelectorPopoverV2View(props: { + trigger: ModelSelectorTrigger + models: (search: string) => ModelItem[] + groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[] + current: () => string | undefined + select: (item: ModelItem) => void + onManage: () => void + onClose: () => void +}) { + const language = useLanguage() + const [store, setStore] = createStore({ open: false, search: "", active: "" }) + let searchRef: HTMLInputElement | undefined + let contentRef: HTMLDivElement | undefined + const dismiss = createMenuDismissController(() => contentRef) + + const models = createMemo(() => props.models(store.search)) + const groups = createMemo(() => props.groups(models())) + const keys = () => [...models().map(modelKey), manageKey] + const initialActive = () => { + const selected = props.current() + const options = keys() + if (selected && options.includes(selected)) return selected + return options[0] ?? "" + } + const activeItem = () => + store.active ? contentRef?.querySelector(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined + const setOpen = (open: boolean) => { + if (open) { + dismiss.allowTriggerRestore() + setStore({ open: true, active: initialActive() }) + setTimeout(() => + requestAnimationFrame(() => { + searchRef?.focus() + activeItem()?.scrollIntoView({ block: "nearest" }) + }), + ) + return + } + setStore({ open: false, search: "", active: "" }) + } + const selectModel = (item: ModelItem) => { + dismiss.preventTriggerRestore() + setOpen(false) + dismiss.afterClose(() => props.select(item)) + } + const manage = () => { + dismiss.preventTriggerRestore() + setOpen(false) + dismiss.afterClose(props.onManage) + } + const selectActive = () => { + const item = models().find((item) => modelKey(item) === store.active) + if (item) { + selectModel(item) + return + } + if (store.active === manageKey) manage() + } + const moveActive = (delta: number) => { + const options = keys() + if (options.length === 0) return + const index = options.indexOf(store.active) + const start = index === -1 ? 0 : index + setStore("active", options[(start + delta + options.length) % options.length]) + queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" })) + } + const setSearch = (value: string) => { + const first = props.models(value)[0] + setStore({ search: value, active: first ? modelKey(first) : manageKey }) + } + + createEffect(() => { + if (!store.open) return + createEventListener( + document, + "keydown", + (event: KeyboardEvent) => handleDocumentSearchKeydown(searchRef, event, store.search, setSearch), + true, + ) + }) + + return ( + + + + (contentRef = element)} + class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none" + onPointerDownOutside={dismiss.preventTriggerRestore} + onFocusOutside={dismiss.preventTriggerRestore} + onCloseAutoFocus={dismiss.onCloseAutoFocus} + > +
+
+ + (searchRef = el)} + value={store.search} + placeholder={language.t("dialog.model.search.placeholder")} + class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint" + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + onInput={(event) => setSearch(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Tab") return + event.stopPropagation() + if (event.key === "Escape") { + event.preventDefault() + dismiss.preventTriggerRestore() + setOpen(false) + dismiss.afterClose(props.onClose) + return + } + if (event.altKey || event.metaKey) return + if (event.key === "ArrowDown") { + event.preventDefault() + moveActive(1) + return + } + if (event.key === "ArrowUp") { + event.preventDefault() + moveActive(-1) + return + } + if (event.key === "Enter" && !event.isComposing) { + event.preventDefault() + selectActive() + } + }} + /> + + + +
+
+
+ +
+ 0} + fallback={ +
+ {language.t("dialog.model.empty")} +
+ } + > + + {(group) => ( + + + {group.items[0].provider.name} + + + + {(item) => ( + + } + > + { + setStore("active", modelKey(item)) + setTimeout(() => searchRef?.focus()) + }} + onSelect={() => selectModel(item)} + > + {item.name} + + {language.t("model.tag.free")} + + + {language.t("model.tag.latest")} + + + + )} + + + + )} + +
+
+
+
+
+ { + setStore("active", manageKey) + setTimeout(() => searchRef?.focus()) + }} + onSelect={manage} + > + + {language.t("dialog.model.manage")} + +
+ + + + ) +} + export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => { const dialog = useDialog() const language = useLanguage() + const local = useLocal() + const directory = () => decode64(local.slug()) const provider = () => { - void import("./dialog-select-provider").then((x) => { - dialog.show(() => ) + void import("./dialog-connect-provider").then((x) => { + void dialog.show(() => ) }) } diff --git a/packages/app/src/components/dialog-select-provider.tsx b/packages/app/src/components/dialog-select-provider.tsx deleted file mode 100644 index 89310286daf2..000000000000 --- a/packages/app/src/components/dialog-select-provider.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { Component, Show } from "solid-js" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { popularProviders, useProviders } from "@/hooks/use-providers" -import { Dialog } from "@opencode-ai/ui/dialog" -import { List } from "@opencode-ai/ui/list" -import { Tag } from "@opencode-ai/ui/tag" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { DialogConnectProvider } from "./dialog-connect-provider" -import { useLanguage } from "@/context/language" -import { DialogCustomProvider } from "./dialog-custom-provider" - -const CUSTOM_ID = "_custom" - -export const DialogSelectProvider: Component = () => { - const dialog = useDialog() - const providers = useProviders() - const language = useLanguage() - - const popularGroup = () => language.t("dialog.provider.group.popular") - const otherGroup = () => language.t("dialog.provider.group.other") - const customLabel = () => language.t("settings.providers.tag.custom") - const note = (id: string) => { - if (id === "anthropic") return language.t("dialog.provider.anthropic.note") - if (id === "openai") return language.t("dialog.provider.openai.note") - if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note") - if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline") - } - - return ( - - x?.id} - items={() => { - language.locale() - return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()] - }} - filterKeys={["id", "name"]} - groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())} - sortBy={(a, b) => { - if (a.id === CUSTOM_ID) return -1 - if (b.id === CUSTOM_ID) return 1 - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - return a.name.localeCompare(b.name) - }} - sortGroupsBy={(a, b) => { - const popular = popularGroup() - if (a.category === popular && b.category !== popular) return -1 - if (b.category === popular && a.category !== popular) return 1 - return 0 - }} - onSelect={(x) => { - if (!x) return - if (x.id === CUSTOM_ID) { - dialog.show(() => ) - return - } - dialog.show(() => ) - }} - > - {(i) => ( -
- - {i.name} - -
{language.t("dialog.provider.opencode.tagline")}
-
- - {language.t("settings.providers.tag.custom")} - - - {language.t("dialog.provider.tag.recommended")} - - {(value) =>
{value()}
}
- - {language.t("dialog.provider.tag.recommended")} - -
- )} -
-
- ) -} diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 08769068909c..aa16976228e6 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -16,6 +16,7 @@ import { useGlobal } from "@/context/global" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" +import { detectServerProtocol } from "@/utils/server-protocol" import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" import { useSettings } from "@/context/settings" import { useTabs } from "@/context/tabs" @@ -263,6 +264,13 @@ export function useServerManagementController(options: { onSelect?: () => void; setStore("addServer", { error: language.t("dialog.server.add.error") }) return } + if ( + !settings.general.newLayoutDesigns() && + (await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2" + ) { + setStore("addServer", { error: language.t("dialog.server.add.error") }) + return + } resetAdd() if (options.navigateOnAdd === false) { @@ -307,6 +315,13 @@ export function useServerManagementController(options: { onSelect?: () => void; setStore("editServer", { error: language.t("dialog.server.add.error") }) return } + if ( + !settings.general.newLayoutDesigns() && + (await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2" + ) { + setStore("editServer", { error: language.t("dialog.server.add.error") }) + return + } if (normalized === input.original.http.url) { server.add(conn) } else { @@ -344,7 +359,10 @@ export function useServerManagementController(options: { onSelect?: () => void; ) const sortedItems = createMemo(() => { - const list = items() + const raw = items() + const list = settings.general.newLayoutDesigns() + ? raw + : raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2") if (!list.length) return list const active = current() const order = new Map(list.map((url, index) => [url, index] as const)) diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index 20d71f4bfd44..b554fa79a822 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -1,22 +1,35 @@ -import { Component } from "solid-js" +import { Component, createSignal, startTransition } from "solid-js" import { Dialog } from "@opencode-ai/ui/dialog" import { Tabs } from "@opencode-ai/ui/tabs" import { Icon } from "@opencode-ai/ui/icon" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" +import { useDialog } from "@opencode-ai/ui/context/dialog" import { SettingsGeneral } from "./settings-general" import { SettingsKeybinds } from "./settings-keybinds" import { SettingsProviders } from "./settings-providers" import { SettingsModels } from "./settings-models" import { SettingsServers } from "./settings-servers" -export const DialogSettings: Component = () => { +export const DialogSettings: Component<{ defaultValue?: string }> = (props) => { const language = useLanguage() const platform = usePlatform() + const dialog = useDialog() + const [tab, setTab] = createSignal(props.defaultValue ?? "general") + + const showProviders = () => { + void dialog.show(() => ) + } return ( - + void startTransition(() => setTab(value))} + class="h-full settings-dialog" + >
@@ -70,7 +83,7 @@ export const DialogSettings: Component = () => { - + diff --git a/packages/app/src/components/dialog-usage-exceeded.tsx b/packages/app/src/components/dialog-usage-exceeded.tsx index e428d4c2bbbb..de6451a63b6a 100644 --- a/packages/app/src/components/dialog-usage-exceeded.tsx +++ b/packages/app/src/components/dialog-usage-exceeded.tsx @@ -1,4 +1,5 @@ import { usePlatform } from "@/context/platform" +import { useLanguage } from "@/context/language" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -14,10 +15,11 @@ export type DialogGoUpsellProps = { export function DialogUsageExceeded(props: DialogGoUpsellProps) { const dialog = useDialog() + const language = useLanguage() const platform = usePlatform() const runAction = () => { - if (props.link) platform.openLink(props.link) + if (props.link) platform.openExternal(props.link) props.onClose?.() dialog.close() } @@ -32,7 +34,7 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) {
- Lorem ipsum dolor sit amet -

- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et - dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation. + + +

+ + + + } + class="absolute top-[10px] start-[-36px]" + /> + +
+

+ {language.t("help.tabs.date")} +

+ + } + /> - -
- +
+
+

+ {language.t("help.tabs.title")} +

+
+

{language.t("help.tabs.introduction")}

+ +

{language.t("help.tabs.sessions")}

+

{language.t("help.tabs.organize")}

+

{language.t("help.tabs.home")}

+ +

{language.t("help.tabs.persistence")}

+

{language.t("help.tabs.worktrees")}

+
+
+ + ) } diff --git a/packages/app/src/components/link.tsx b/packages/app/src/components/link.tsx deleted file mode 100644 index 85f7efc539eb..000000000000 --- a/packages/app/src/components/link.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { ComponentProps, splitProps } from "solid-js" -import { usePlatform } from "@/context/platform" - -export interface LinkProps extends Omit, "href"> { - href: string -} - -export function Link(props: LinkProps) { - const platform = usePlatform() - const [local, rest] = splitProps(props, ["href", "children", "class"]) - - return ( - { - if (!local.href) return - event.preventDefault() - platform.openLink(local.href) - }} - {...rest} - > - {local.children} - - ) -} diff --git a/packages/app/src/components/model-tooltip.tsx b/packages/app/src/components/model-tooltip.tsx index 53164dae85e2..7253c069173f 100644 --- a/packages/app/src/components/model-tooltip.tsx +++ b/packages/app/src/components/model-tooltip.tsx @@ -1,4 +1,4 @@ -import { Show, type Component } from "solid-js" +import { Show, type Component, type JSX } from "solid-js" import { useLanguage } from "@/context/language" type InputKey = "text" | "image" | "audio" | "video" | "pdf" @@ -23,7 +23,18 @@ type ModelInfo = { } } -export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean }> = (props) => { +function ModelTooltipRow(props: { name: JSX.Element; value: JSX.Element }) { + return ( +
+ {props.name} + {props.value} +
+ ) +} + +export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean; v2?: boolean }> = ( + props, +) => { const language = useLanguage() const sourceName = (model: ModelInfo) => { const value = `${model.id} ${model.name}`.toLowerCase() @@ -51,6 +62,13 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free? const suffix = tags.length ? ` (${tags.join(", ")})` : "" return `${sourceName(props.model)} ${props.model.name}${suffix}` } + const name = () => { + const tags: Array = [] + if (props.latest) tags.push(language.t("model.tag.latest")) + if (props.free) tags.push(language.t("model.tag.free")) + const suffix = tags.length ? ` (${tags.join(", ")})` : "" + return `${props.model.name}${suffix}` + } const inputs = () => { if (props.model.capabilities) { const input = props.model.capabilities.input @@ -73,6 +91,21 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free? : language.t("model.tooltip.reasoning.none") } const context = () => language.t("model.tooltip.context", { limit: props.model.limit.context.toLocaleString() }) + const contextLimit = () => props.model.limit.context.toLocaleString(language.intl()) + + if (props.v2) { + return ( +
+ + + + {(value) => } + + + +
+ ) + } return (
diff --git a/packages/app/src/components/pierre-tree.test.ts b/packages/app/src/components/pierre-tree.test.ts new file mode 100644 index 000000000000..7bd40a11d0e7 --- /dev/null +++ b/packages/app/src/components/pierre-tree.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test" +import { FileTree, type FileTreeDirectoryHandle } from "@pierre/trees" + +test("reports directory expansion changes", () => { + const changes: Array<{ path: string; expanded: boolean }> = [] + const tree = new FileTree({ + paths: ["src/"], + onExpansionChange: (change) => changes.push(change), + }) + + const src = tree.getItem("src/") + if (!src || !src.isDirectory()) throw new Error("Expected src to be a directory") + const directory = src as FileTreeDirectoryHandle + + directory.expand() + directory.collapse() + + expect(changes).toEqual([ + { path: "src/", expanded: true }, + { path: "src/", expanded: false }, + ]) + tree.cleanUp() +}) diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx new file mode 100644 index 000000000000..481b7922cf36 --- /dev/null +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -0,0 +1,589 @@ +import { ImagePreview } from "@opencode-ai/ui/image-preview" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" +import { createEffect, createMemo, on, Show } from "solid-js" +import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model" +import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" +import type { PromptInputProps } from "@/components/prompt-input/contracts" +import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history" +import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store" +import { promptDesignPlaceholder, promptPlaceholder } from "@/components/prompt-input/placeholder" +import { createPromptSubmit } from "@/components/prompt-input/submit" +import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" +import { useComments } from "@/context/comments" +import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { usePermission } from "@/context/permission" +import { type ImageAttachmentPart, usePrompt } from "@/context/prompt" +import { usePlatform } from "@/context/platform" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { createSessionTabs } from "@/pages/session/helpers" +import { showToast } from "@/utils/toast" +import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input" +import { + createPromptInputV2Controller, + createPromptInputV2State, + type PromptInputV2Interaction, +} from "@opencode-ai/session-ui/v2/prompt-input/interaction" + +export type PromptInputV2ComposerProps = { + class?: string + controller: PromptInputV2ComposerController + borderUnderlay?: boolean +} + +export type PromptInputV2ControllerProps = Omit +export type PromptInputV2ComposerController = PromptInputV2Interaction & { + readonly model: PromptInputProps["controls"]["model"] +} + +export function PromptInputV2Composer(props: PromptInputV2ComposerProps) { + const dialog = useDialog() + const command = useCommand() + const language = useLanguage() + + return ( +
+ + dialog.show(() => ) + } + /> + } + /> +
+ ) +} + +export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController { + const sdk = useSDK() + const sync = useSync() + const files = useFile() + const layout = useLayout() + const comments = useComments() + const dialog = useDialog() + const command = useCommand() + const permission = usePermission() + const language = useLanguage() + const platform = usePlatform() + const prompt = props.state ?? usePrompt() + let editor: HTMLDivElement | undefined + + const interaction = createPromptInputV2State() + const mode = () => interaction[0].mode + const history = props.history ?? createPersistedPromptInputHistory() + const tabs = () => props.controls.session.tabs + const activeFileTab = createSessionTabs({ + tabs, + pathFromTab: files.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab), + }).activeFileTab + const recent = createMemo(() => { + const all = tabs().all() + const active = activeFileTab() + const order = active ? [active, ...all.filter((tab) => tab !== active)] : all + return order.reduce((result, tab) => { + const path = files.pathFromTab(tab) + if (!path || result.includes(path)) return result + return [...result, path] + }, []) + }) + const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined)) + const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? "")) + const attachments = createMemo(() => + prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), + ) + const commentCount = createMemo(() => { + if (mode() === "shell") return 0 + return prompt.context.items().filter((item) => !!item.comment?.trim()).length + }) + const blank = createMemo(() => { + const text = prompt + .current() + .map((part) => ("content" in part ? part.content : "")) + .join("") + return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0 + }) + const stopping = createMemo(() => working() && blank()) + const placeholder = createMemo(() => + promptPlaceholder({ + mode: mode(), + commentCount: commentCount(), + example: mode() === "shell" ? "git status" : "", + suggest: false, + t: (key, params) => language.t(key as Parameters[0], params as never), + }), + ) + const designPlaceholder = () => + promptDesignPlaceholder(mode(), placeholder(), (key, params) => + language.t(key as Parameters[0], params as never), + ) + + const historyComments = () => { + const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) + return prompt.context.items().flatMap((item) => { + const comment = item.comment?.trim() + if (!comment) return [] + const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined + const nextSelection = + selection ?? + (item.selection + ? ({ start: item.selection.startLine, end: item.selection.endLine } satisfies SelectedLineRange) + : undefined) + if (!nextSelection) return [] + return [ + { + id: item.commentID ?? item.key, + path: item.path, + selection: { ...nextSelection }, + comment, + time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(), + origin: item.commentOrigin, + preview: item.preview, + } satisfies PromptHistoryComment, + ] + }) + } + const restoreHistoryComments = (items: PromptHistoryComment[]) => { + comments.replace( + items.map((item) => ({ + id: item.id, + file: item.path, + selection: { ...item.selection }, + comment: item.comment, + time: item.time, + })), + ) + prompt.context.replaceComments( + items.map((item) => ({ + type: "file", + path: item.path, + selection: selectionFromLines(item.selection), + comment: item.comment, + commentID: item.id, + commentOrigin: item.origin, + preview: item.preview, + })), + ) + } + + const accepting = createMemo(() => { + const id = props.controls.session.id + if (!id) return permission.isAutoAcceptingDirectory(sdk().directory) + return permission.isAutoAccepting(id, sdk().directory) + }) + const submission = createPromptSubmit({ + prompt, + info, + imageAttachments: attachments, + commentCount, + autoAccept: accepting, + mode, + working, + editor: () => editor, + queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })), + promptLength, + addToHistory: (value, mode) => controller.addHistory(value, mode), + resetHistoryNavigation: () => controller.resetHistory(), + setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }), + setPopover: (popover) => { + if (!popover) controller.dispatch({ type: "popover.close" }) + }, + newSessionWorktree: () => props.newSessionWorktree, + onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, + shouldQueue: props.shouldQueue, + onQueue: props.onQueue, + onAbort: props.onAbort, + onSubmit: props.onSubmit, + model: props.controls.model.selection, + }) + + const referenceDescription = (reference: ReferenceInfo) => + reference.source.type === "git" ? reference.source.repository : reference.source.path + const references = createMemo(() => + sync() + .data.reference.filter((reference) => !reference.hidden) + .map((reference) => ({ + id: `reference:${reference.name}`, + kind: "reference" as const, + label: `@${reference.name}`, + path: reference.path, + description: reference.description ?? referenceDescription(reference), + mention: { + type: "file" as const, + path: reference.path, + content: `@${reference.name}`, + start: 0, + end: 0, + mime: "application/x-directory", + filename: reference.name, + }, + })), + ) + const resources = createMemo(() => + Object.values(sync().data.mcp_resource).map((resource) => ({ + id: `resource:${resource.server}:${resource.uri}`, + kind: "resource" as const, + label: `@${resource.name}`, + path: resource.uri, + description: resource.description, + mention: { + type: "file" as const, + path: resource.uri, + content: `@${resource.name}`, + start: 0, + end: 0, + mime: resource.mimeType ?? "text/plain", + filename: resource.name, + url: resource.uri, + source: { + type: "resource" as const, + text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 }, + clientName: resource.server, + uri: resource.uri, + }, + }, + resource, + })), + ) + const context = createMemo(() => [ + ...references(), + ...props.controls.agents.available + .filter((agent) => !agent.hidden && agent.mode !== "primary") + .map((agent) => ({ + id: `agent:${agent.name}`, + kind: "agent" as const, + label: `@${agent.name}`, + mention: { type: "agent" as const, name: agent.name, content: `@${agent.name}`, start: 0, end: 0 }, + })), + ...resources(), + ...recent().map((path) => ({ + id: `file:${path}`, + kind: "file" as const, + label: path, + path, + recent: true, + mention: { type: "file" as const, path, content: `@${path}`, start: 0, end: 0 }, + })), + ]) + const slashCommands = createMemo(() => [ + ...sync().data.command.map((item) => ({ + id: `custom.${item.name}`, + trigger: item.name, + title: item.name, + description: item.description, + type: "custom" as const, + })), + ...command.options + .filter((item) => !item.disabled && !item.id.startsWith("suggested.") && item.slash) + .map((item) => ({ + id: item.id, + trigger: item.slash!, + title: item.title, + description: item.description, + type: "builtin" as const, + })), + ]) + const commands = createMemo(() => + slashCommands().map((item) => ({ + id: item.id, + kind: "command", + label: `/${item.trigger}`, + trigger: item.trigger, + title: item.title, + description: item.description, + keybind: command.keybindParts(item.id), + })), + ) + const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()]) + const controller = createPromptInputV2Controller({ + store: () => prompt.capture().store, + state: interaction, + identity: () => prompt.capture(), + history: { + entries: (mode) => + history.entries(mode).map((value) => { + const entry = normalizePromptHistoryEntry(value) + return { prompt: entry.prompt, metadata: entry.comments } + }), + add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()), + capture: historyComments, + restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]), + }, + commands, + context, + searchContextFiles: async (query) => + (await files.searchFilesAndDirectories(query)).map((path) => ({ + id: `file:${path}`, + kind: "file", + label: path, + path, + mention: { type: "file", path, content: `@${path}`, start: 0, end: 0 }, + })), + onContextRemove(item) { + if (item?.commentID) comments.remove(item.path, item.commentID) + }, + openAttachment: (attachment) => + dialog.show(() => ), + openContext(key) { + const item = controller.contextItem(key) + if (item) openComment(item, props, sync, layout, files, comments) + }, + onEditor(element) { + editor = element as HTMLDivElement + props.ref?.(editor) + }, + onSuggestionSelect(item) { + if (item.kind !== "command") return + const selected = slashCommands().find((entry) => entry.id === item.id) + if (!selected || selected.type === "custom") return + return () => command.trigger(selected.id, "slash") + }, + attachments: { + picker: platform.openAttachmentPickerDialog, + directory: () => sdk().directory, + isDialogActive: () => !!dialog.active, + warn: () => + showToast({ + title: language.t("prompt.toast.pasteUnsupported.title"), + description: language.t("prompt.toast.pasteUnsupported.description"), + }), + duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }), + onError: (error) => + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }), + readClipboardImage: platform.readClipboardImage, + getPathForFile: platform.getPathForFile, + store: platform.draftStore?.putBlob, + }, + view: { + placeholder: designPlaceholder, + get agent() { + return props.controls.agents.visible && props.controls.agents.options.length > 0 + ? { + options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })), + current: () => props.controls.agents.current, + onSelect: (value: string) => props.controls.agents.select(value), + keybind: () => command.keybindParts("agent.cycle"), + } + : undefined + }, + variant: { + options: () => variants().map((value) => ({ id: value, label: value })), + current: () => props.controls.model.selection.variant.current() ?? "default", + onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value), + keybind: () => command.keybindParts("model.variant.cycle"), + }, + submit: { + stopping, + working, + onSubmit: () => void submission.handleSubmit(new Event("submit")), + onStop: () => void submission.abort(), + }, + }, + }) + Object.defineProperty(controller, "model", { get: () => props.controls.model }) + + command.register("prompt-input", () => [ + { + id: "file.attach", + title: language.t("prompt.action.attachFile"), + category: language.t("command.category.file"), + keybind: "mod+u", + disabled: controller.state.mode !== "normal", + onSelect: () => controller.attach(), + }, + { + id: "prompt.mode.shell", + title: language.t("command.prompt.mode.shell"), + category: language.t("command.category.session"), + keybind: "mod+shift+x", + disabled: controller.state.mode === "shell", + onSelect: () => controller.dispatch({ type: "mode.shell" }), + }, + { + id: "prompt.mode.normal", + title: language.t("command.prompt.mode.normal"), + category: language.t("command.category.session"), + keybind: "mod+shift+e", + disabled: controller.state.mode === "normal", + onSelect: () => controller.dispatch({ type: "mode.normal" }), + }, + ]) + + createEffect( + on( + () => props.edit?.id, + (id) => { + const edit = props.edit + if (!id || !edit) return + prompt.context.items().forEach((item) => prompt.context.remove(item.key)) + edit.context.forEach((item) => + prompt.context.add({ + type: item.type, + path: item.path, + selection: item.selection, + comment: item.comment, + commentID: item.commentID, + commentOrigin: item.commentOrigin, + preview: item.preview, + }), + ) + controller.dispatch({ type: "mode.normal" }) + controller.resetHistory() + prompt.set(edit.prompt, promptLength(edit.prompt)) + controller.restoreFocus() + props.onEditLoaded?.() + }, + { defer: true }, + ), + ) + + return controller as PromptInputV2ComposerController +} + +function PromptInputV2ModelControl(props: { + loading: boolean + paid: boolean + title: string + keybind: string[] + model: PromptInputV2ComposerController["model"]["selection"] + providerID?: string + modelName: string + onClose: () => void + onUnpaidClick: () => void +}) { + const shouldAnimate = createMemo((previous) => previous ?? props.loading) + const content = () => ( + <> + + {(providerID) => ( + + )} + + {props.modelName} + + + + + ) + return ( + + + {props.title} + + + } + > + + {content()} + + } + > + ( + + {content()} + + )} + onClose={props.onClose} + /> + + + + ) +} + +function openComment( + item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }, + props: PromptInputV2ControllerProps, + sync: ReturnType, + layout: ReturnType, + files: ReturnType, + comments: ReturnType, +) { + if (!item.commentID) return + const focus = { file: item.path, id: item.commentID } + comments.setActive(focus) + const queueFocus = (attempts = 6) => { + requestAnimationFrame(() => { + comments.setFocus({ ...focus }) + if (attempts <= 0) return + requestAnimationFrame(() => { + const current = comments.focus() + if (current?.file === focus.file && current.id === focus.id) queueFocus(attempts - 1) + }) + }) + } + const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined + const review = + item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path)) + if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() + if (review) { + layout.fileTree.setTab("changes") + props.controls.session.tabs.setActive("review") + queueFocus() + return + } + layout.fileTree.setTab("all") + const tab = files.tab(item.path) + void props.controls.session.tabs.open(tab) + props.controls.session.tabs.setActive(tab) + void Promise.resolve(files.load(item.path)).finally(() => queueFocus()) +} diff --git a/packages/app/src/components/prompt-input.stories.tsx b/packages/app/src/components/prompt-input.stories.tsx new file mode 100644 index 000000000000..0b9c26ce411b --- /dev/null +++ b/packages/app/src/components/prompt-input.stories.tsx @@ -0,0 +1,223 @@ +// @ts-nocheck +import { createStore } from "solid-js/store" +import type { Todo } from "@opencode-ai/sdk/v2" +import { createPromptState } from "@/context/prompt" +import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer" +import { createPromptInputHistory, PromptInput } from "./prompt-input" + +function createPromptInputStoryRuntime() { + const state = createPromptState() + return { + state, + history: createPromptInputHistory(), + submission: { + abort() {}, + handleSubmit(event: Event) { + event.preventDefault() + state.reset() + }, + }, + } +} + +function PromptInputExample() { + const input = createPromptInputStoryRuntime() + const [controls, setControls] = createStore({ + agent: "build", + variant: undefined as string | undefined, + comments: 0, + tabs: [] as string[], + activeTab: undefined as string | undefined, + reviewOpen: false, + }) + const storyModel = { + id: "claude-3-7-sonnet", + name: "Claude 3.7 Sonnet", + provider: { id: "anthropic", name: "Anthropic" }, + } + const model = { + current: () => storyModel, + list: () => [storyModel], + visible: () => true, + set: () => {}, + variant: { + list: () => ["fast", "thinking"], + current: () => controls.variant, + set: (variant?: string) => setControls("variant", variant), + }, + } + const inputControls = { + agents: { + available: [{ name: "review", hidden: false, mode: "subagent" }], + options: ["build", "review", "plan"], + get current() { + return controls.agent + }, + loading: false, + visible: true, + select: (agent?: string) => setControls("agent", agent ?? "build"), + }, + model: { + selection: model, + paid: true, + loading: false, + }, + session: { + id: "story-session", + tabs: { + active: () => controls.activeTab, + all: () => controls.tabs, + open: (tab: string) => setControls("tabs", (tabs) => (tabs.includes(tab) ? tabs : [...tabs, tab])), + setActive: (tab: string) => setControls("activeTab", tab), + }, + reviewPanel: { + opened: () => controls.reviewOpen, + open: () => setControls("reviewOpen", true), + }, + }, + } + const addReviewComment = () => { + const comment = controls.comments + 1 + setControls("comments", comment) + input.state.context.add({ + type: "file", + path: "src/components/prompt-input.tsx", + selection: { + startLine: 84 + comment, + startChar: 0, + endLine: 84 + comment, + endChar: 0, + }, + comment: `Review comment ${comment}`, + commentID: `review-comment-${comment}`, + commentOrigin: "review", + preview: "export const PromptInput = ...", + }) + } + + return ( +
+ +
+ +
+
+ ) +} + +const todos: Todo[] = [ + { id: "todo-1", content: "Inspect the session composer animation", status: "completed" }, + { id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" }, + { id: "todo-3", content: "Verify session navigation behavior", status: "pending" }, +] + +function PromptInputWithOpenDock() { + const input = createPromptInputStoryRuntime() + const [controls, setControls] = createStore({ + agent: "build", + activeTab: undefined as string | undefined, + todoCollapsed: false, + }) + const inputControls = { + agents: { + available: [], + options: ["build"], + get current() { + return controls.agent + }, + loading: false, + visible: true, + select: (agent?: string) => setControls("agent", agent ?? "build"), + }, + model: { + selection: { + current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }), + variant: { list: () => [], current: () => undefined, set: () => {} }, + }, + paid: true, + loading: false, + }, + session: { + id: "story-session", + tabs: { + active: () => controls.activeTab, + all: () => [], + open: () => {}, + setActive: (tab: string) => setControls("activeTab", tab), + }, + reviewPanel: { opened: () => false, open: () => {} }, + }, + } + const state = { + blocked: () => false, + questionRequest: () => undefined, + permissionRequest: () => undefined, + permissionResponding: () => false, + decide: () => {}, + todos: () => todos, + dock: () => true, + closing: () => false, + opening: () => false, + } + return ( + "story-session", + sessionID: () => "story-session", + prompt: input.state, + ready: () => true, + centered: () => false, + todo: { + collapsed: () => controls.todoCollapsed, + onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed), + }, + followup: () => undefined, + revert: () => undefined, + onResponseSubmit: () => {}, + openParent: () => {}, + setPromptRef: () => {}, + setDockRef: () => {}, + })} + promptInput={ + {}} + newSessionWorktree="" + onNewSessionWorktreeReset={() => {}} + /> + } + /> + ) +} + +export default { + title: "App/PromptInput", + id: "app-prompt-input", + component: PromptInput, +} + +export const Basic = { + render: () => ( +
+

Prompt Input

+ +
+ ), +} + +export const DockAlreadyOpen = { + render: () => ( +
+

Prompt Input with open Todo dock

+ +
+ ), +} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index bdf55fee0564..41cf7d30ca34 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -4,8 +4,6 @@ import { createEffect, on, Component, - splitProps, - For, Show, onCleanup, createMemo, @@ -13,16 +11,13 @@ import { createResource, Switch, Match, - type ComponentProps, type JSX, } from "solid-js" -import { Popover as KobaltePopover } from "@kobalte/core/popover" -import { createStore } from "solid-js/store" -import { useLocal } from "@/context/local" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" import { ContentPart, DEFAULT_PROMPT, + isCommentItem, isPromptEqual, Prompt, usePrompt, @@ -31,29 +26,30 @@ import { FileAttachmentPart, } from "@/context/prompt" import { useLayout } from "@/context/layout" -import { useNavigate } from "@solidjs/router" import { useSDK } from "@/context/sdk" -import { useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useComments } from "@/context/comments" import { Button } from "@opencode-ai/ui/button" import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" -import { Icon, type IconProps } from "@opencode-ai/ui/icon" +import { Icon } from "@opencode-ai/ui/icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { ModelSelectorPopover } from "@/components/dialog-select-model" -import { useProviders } from "@/hooks/use-providers" +import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model" +import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" +import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" import { useCommand } from "@/context/command" -import { Persist, persisted } from "@/utils/persist" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" -import { useSettings } from "@/context/settings" -import { serverAttachmentFile } from "./prompt-input/server-attachment" -import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createPromptAttachments } from "./prompt-input/attachments" @@ -61,40 +57,34 @@ import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files" import { canNavigateHistoryAtCursor, navigatePromptHistory, - prependHistoryEntry, type PromptHistoryComment, type PromptHistoryEntry, - type PromptHistoryStoredEntry, promptLength, } from "./prompt-input/history" -import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit" +import { + createPersistedPromptInputHistory, + createPromptInputHistory, + type PromptInputHistory, +} from "./prompt-input/history-store" +import { + type PromptInputControls, + type PromptInputProps, + type PromptInputState, + type PromptInputSubmission, +} from "./prompt-input/contracts" +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 { useDirectoryPicker } from "./directory-picker" +import { createPromptInputTransientState } from "./prompt-input/transient-state" import { showToast } from "@/utils/toast" import { ImagePreview } from "@opencode-ai/ui/image-preview" -import { useQueries } from "@tanstack/solid-query" -import { useQueryOptions } from "@/context/server-sync" -import { pathKey } from "@/utils/path-key" -import { base64Encode } from "@opencode-ai/core/util/encode" -import { displayName } from "@/pages/layout/helpers" - -interface PromptInputProps { - class?: string - variant?: "dock" | "new-session" - ref?: (el: HTMLDivElement) => void - newSessionWorktree?: string - onNewSessionWorktreeReset?: () => void - edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] } - onEditLoaded?: () => void - shouldQueue?: () => boolean - onQueue?: (draft: FollowupDraft) => void - onAbort?: () => void - onSubmit?: () => void -} +import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" + +export { createPromptInputHistory } +export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission } const EXAMPLES = [ "prompt.example.1", @@ -126,30 +116,24 @@ const EXAMPLES = [ export const PromptInput: Component = (props) => { const sdk = useSDK() - const navigate = useNavigate() - const queryOptions = useQueryOptions() const sync = useSync() - const local = useLocal() const files = useFile() - const prompt = usePrompt() + const prompt = props.state ?? usePrompt() const layout = useLayout() - const server = useServer() const comments = useComments() const dialog = useDialog() - const providers = useProviders() const command = useCommand() const permission = usePermission() const language = useLanguage() const platform = usePlatform() - const pickDirectory = useDirectoryPicker() - const settings = useSettings() - const { params, tabs, view } = useSessionLayout() + const tabs = () => props.controls.session.tabs let editorRef!: HTMLDivElement let fileInputRef: HTMLInputElement | undefined let scrollRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement - let projectSearchRef: HTMLInputElement | undefined + let restoreEndOnFocus = true + let savedCursor: number | null = null const mirror = { input: false } const inset = 56 @@ -202,10 +186,10 @@ export const PromptInput: Component = (props) => { }).activeFileTab const commentInReview = (path: string) => { - const sessionID = params.id + const sessionID = props.controls.session.id if (!sessionID) return false - const diffs = sync.data.session_diff[sessionID] + const diffs = sync().data.session_diff[sessionID] if (!diffs) return false return diffs.some((diff) => diff.file === path) } @@ -235,14 +219,14 @@ export const PromptInput: Component = (props) => { const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path)) if (wantsReview) { - if (!view().reviewPanel.opened()) view().reviewPanel.open() + if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() layout.fileTree.setTab("changes") tabs().setActive("review") queueCommentFocus() return } - if (!view().reviewPanel.opened()) view().reviewPanel.open() + if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() layout.fileTree.setTab("all") const tab = files.tab(item.path) void tabs().open(tab) @@ -267,36 +251,16 @@ export const PromptInput: Component = (props) => { return paths }) - const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) - const working = createMemo(() => sync.data.session_working(params.id ?? "")) + const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined)) + const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? "")) const imageAttachments = createMemo(() => prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), ) - const [store, setStore] = createStore<{ - popover: "at" | "slash" | null - historyIndex: number - savedPrompt: PromptHistoryEntry | null - placeholder: number - draggingType: "image" | "@mention" | null - mode: "normal" | "shell" - applyingHistory: boolean - variantOpen: boolean - }>({ - popover: null, - historyIndex: -1, - savedPrompt: null as PromptHistoryEntry | null, - placeholder: Math.floor(Math.random() * EXAMPLES.length), - draggingType: null, - mode: "normal", - applyingHistory: false, - variantOpen: false, - }) - const [picker, setPicker] = createStore({ - projectOpen: false, - projectSearch: "", - }) - + const [store, setStore] = createPromptInputTransientState( + () => prompt.capture(), + Math.floor(Math.random() * EXAMPLES.length), + ) const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) const motion = (value: number) => ({ opacity: value, @@ -345,29 +309,14 @@ export const PromptInput: Component = (props) => { }) const hasUserPrompt = createMemo(() => { - const sessionID = params.id + const sessionID = props.controls.session.id if (!sessionID) return false - const messages = sync.data.message[sessionID] + const messages = sync().data.message[sessionID] if (!messages) return false return messages.some((m) => m.role === "user") }) - const [history, setHistory] = persisted( - Persist.global("prompt-history", ["prompt-history.v1"]), - createStore<{ - entries: PromptHistoryStoredEntry[] - }>({ - entries: [], - }), - ) - const [shellHistory, setShellHistory] = persisted( - Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]), - createStore<{ - entries: PromptHistoryStoredEntry[] - }>({ - entries: [], - }), - ) + const history = props.history ?? createPersistedPromptInputHistory() const suggest = createMemo(() => !hasUserPrompt()) @@ -470,39 +419,23 @@ export const PromptInput: Component = (props) => { const escBlur = () => platform.platform === "desktop" && platform.os === "macos" const pick = () => { - if (server.isLocal()) { - pickAttachmentFiles({ - picker: platform.openAttachmentPickerDialog, - directory: () => sdk.directory, - fallback: () => fileInputRef?.click(), - onFile: addAttachment, - onError: (error) => - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: error instanceof Error ? error.message : String(error), - }), - }) - return - } - void import("@/components/dialog-select-file").then((module) => - dialog.show(() => ( - { - void sdk.client.v2.fs - .read({ path }) - .then((response) => response.data?.data) - .then((data) => data && addAttachments([serverAttachmentFile(path, data)])) - }} - /> - )), - ) + pickAttachmentFiles({ + picker: platform.openAttachmentPickerDialog, + directory: () => sdk().directory, + fallback: () => fileInputRef?.click(), + onFile: addAttachment, + onError: (error) => + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }), + }) } const setMode = (mode: "normal" | "shell") => { setStore("mode", mode) - setStore("popover", null) + setStore({ popover: null, slashMenu: false, slashMenuQuery: "" }) requestAnimationFrame(() => editorRef?.focus()) } @@ -536,7 +469,7 @@ export const PromptInput: Component = (props) => { }, ]) - const closePopover = () => setStore("popover", null) + const closePopover = () => setStore({ popover: null, slashMenu: false, slashMenuQuery: "" }) const resetHistoryNavigation = (force = false) => { if (!force && (store.historyIndex < 0 || store.applyingHistory)) return @@ -573,13 +506,23 @@ export const PromptInput: Component = (props) => { const restoreFocus = () => { requestAnimationFrame(() => { - const cursor = prompt.cursor() ?? promptLength(prompt.current()) + const cursor = savedCursor ?? prompt.cursor() ?? promptLength(prompt.current()) editorRef.focus() setCursorPosition(editorRef, cursor) queueScroll() }) } + const handleFocus = () => { + if (!restoreEndOnFocus) return + restoreEndOnFocus = false + requestAnimationFrame(() => { + if (document.activeElement !== editorRef) return + setCursorPosition(editorRef, prompt.cursor() ?? promptLength(prompt.current())) + queueScroll() + }) + } + const renderEditorWithCursor = (parts: Prompt) => { const cursor = currentCursor() renderEditor(parts) @@ -587,8 +530,8 @@ export const PromptInput: Component = (props) => { } createEffect(() => { - params.id - if (params.id) return + props.controls.session.id + if (props.controls.session.id) return if (!suggest()) return const interval = setInterval(() => { setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length) @@ -600,6 +543,9 @@ export const PromptInput: Component = (props) => { const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 const handleBlur = () => { + const cursor = currentCursor() + savedCursor = cursor + if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor) closePopover() setComposing(false) } @@ -616,25 +562,89 @@ export const PromptInput: Component = (props) => { }) } + const referenceDescription = (reference: ReferenceInfo) => + reference.source.type === "git" ? reference.source.repository : reference.source.path + + const referenceList = createMemo(() => + sync() + .data.reference.filter((reference) => !reference.hidden) + .map( + (reference): AtOption => ({ + type: "reference", + name: reference.name, + path: reference.path, + display: reference.name, + description: reference.description ?? referenceDescription(reference), + }), + ), + ) + const agentList = createMemo(() => - sync.data.agent + props.controls.agents.available .filter((agent) => !agent.hidden && agent.mode !== "primary") .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), ) - const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name)) + + const mcpResourceList = createMemo(() => + Object.values(sync().data.mcp_resource).map( + (resource): AtOption => ({ + type: "resource", + name: resource.name, + uri: resource.uri, + client: resource.server, + display: resource.name, + description: resource.description, + mime: resource.mimeType, + }), + ), + ) const handleAtSelect = (option: AtOption | undefined) => { if (!option) return if (option.type === "agent") { addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 }) - } else { - addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) + return + } + if (option.type === "reference") { + addPart({ + type: "file", + path: option.path, + content: "@" + option.name, + start: 0, + end: 0, + mime: "application/x-directory", + filename: option.name, + }) + return + } + if (option.type === "resource") { + addPart({ + type: "file", + path: option.uri, + content: "@" + option.name, + start: 0, + end: 0, + mime: option.mime ?? "text/plain", + filename: option.name, + url: option.uri, + source: { + type: "resource", + text: { value: "@" + option.name, start: 0, end: 0 }, + clientName: option.client, + uri: option.uri, + }, + }) + return } + addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) } const atKey = (x: AtOption | undefined) => { if (!x) return "" - return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}` + if (x.type === "agent") return `agent:${x.name}` + if (x.type === "reference") return `reference:${x.name}` + if (x.type === "resource") return `resource:${x.client}:${x.uri}` + return `file:${x.path}` } const { @@ -645,30 +655,36 @@ export const PromptInput: Component = (props) => { onKeyDown: atOnKeyDown, } = useFilteredList({ items: async (query) => { + const references = referenceList() const agents = agentList() + const mcpResources = mcpResourceList() const open = recent() const seen = new Set(open) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) - if (!query.trim()) return [...agents, ...pinned] + if (!query.trim()) return [...references, ...agents, ...mcpResources, ...pinned] const paths = await files.searchFilesAndDirectories(query) const fileOptions: AtOption[] = paths .filter((path) => !seen.has(path)) .map((path) => ({ type: "file", path, display: path })) - return [...agents, ...pinned, ...fileOptions] + return [...references, ...agents, ...mcpResources, ...pinned, ...fileOptions] }, key: atKey, filterKeys: ["display"], skipFilter: (item) => item.type === "file" && !item.recent, groupBy: (item) => { + if (item.type === "reference") return "reference" if (item.type === "agent") return "agent" + if (item.type === "resource") return "resource" if (item.recent) return "recent" return "file" }, sortGroupsBy: (a, b) => { const rank = (category: string) => { - if (category === "agent") return 0 - if (category === "recent") return 1 - return 2 + if (category === "reference") return 0 + if (category === "agent") return 1 + if (category === "resource") return 2 + if (category === "recent") return 3 + return 4 } return rank(a.category) - rank(b.category) }, @@ -687,13 +703,13 @@ export const PromptInput: Component = (props) => { type: "builtin" as const, })) - const custom = sync.data.command.map((cmd) => ({ + const custom = sync().data.command.map((cmd) => ({ id: `custom.${cmd.name}`, trigger: cmd.name, title: cmd.name, description: cmd.description, type: "custom" as const, - source: cmd.source, + // source: cmd.source, })) return [...custom, ...builtin] @@ -701,17 +717,30 @@ export const PromptInput: Component = (props) => { const handleSlashSelect = (cmd: SlashCommand | undefined) => { if (!cmd) return + const menu = store.slashMenu closePopover() const images = imageAttachments() if (cmd.type === "custom") { const text = `/${cmd.trigger} ` + if (menu) { + editorRef.focus() + setCursorPosition(editorRef, 0) + addPart({ type: "text", content: text, start: 0, end: text.length }) + focusEditorEnd() + return + } setEditorText(text) prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length) focusEditorEnd() return } + if (menu) { + command.trigger(cmd.id, "slash") + return + } + clearEditor() prompt.set([...DEFAULT_PROMPT, ...images], 0) command.trigger(cmd.id, "slash") @@ -734,7 +763,17 @@ export const PromptInput: Component = (props) => { const pill = document.createElement("span") pill.textContent = part.content pill.setAttribute("data-type", part.type) - if (part.type === "file") pill.setAttribute("data-path", part.path) + if (part.type === "file") { + pill.setAttribute("data-path", part.path) + if (part.mime) pill.setAttribute("data-mime", part.mime) + if (part.filename) pill.setAttribute("data-filename", part.filename) + if (part.url) pill.setAttribute("data-url", part.url) + if (part.source?.type === "resource") { + pill.setAttribute("data-source-type", part.source.type) + pill.setAttribute("data-source-client-name", part.source.clientName) + pill.setAttribute("data-source-uri", part.source.uri) + } + } if (part.type === "agent") pill.setAttribute("data-name", part.name) pill.setAttribute("contenteditable", "false") pill.style.userSelect = "text" @@ -779,8 +818,7 @@ export const PromptInput: Component = (props) => { } } - // Auto-scroll active command into view when navigating with keyboard - createEffect(() => { + const scrollSlashActiveIntoView = () => { const activeId = slashActive() if (!activeId || !slashPopoverRef) return @@ -788,7 +826,7 @@ export const PromptInput: Component = (props) => { const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`) element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) }) - }) + } const selectPopoverActive = () => { if (store.popover === "at") { const items = atFlat() @@ -850,12 +888,29 @@ export const PromptInput: Component = (props) => { const pushFile = (file: HTMLElement) => { const content = file.textContent ?? "" + const source = + file.dataset.sourceType === "resource" && file.dataset.sourceClientName && file.dataset.sourceUri + ? { + type: "resource" as const, + text: { + value: content, + start: position, + end: position + content.length, + }, + clientName: file.dataset.sourceClientName, + uri: file.dataset.sourceUri, + } + : undefined parts.push({ type: "file", path: file.dataset.path!, content, start: position, end: position + content.length, + ...(file.dataset.mime ? { mime: file.dataset.mime } : {}), + ...(file.dataset.filename ? { filename: file.dataset.filename } : {}), + ...(file.dataset.url ? { url: file.dataset.url } : {}), + ...(source ? { source } : {}), }) position += content.length } @@ -947,10 +1002,10 @@ export const PromptInput: Component = (props) => { if (atMatch) { atOnInput(atMatch[1]) - setStore("popover", "at") + setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" }) } else if (slashMatch) { slashOnInput(slashMatch[1]) - setStore("popover", "slash") + setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" }) } else { closePopover() } @@ -1047,11 +1102,7 @@ export const PromptInput: Component = (props) => { } const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { - const currentHistory = mode === "shell" ? shellHistory : history - const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory - const next = prependHistoryEntry(currentHistory.entries, prompt, mode === "shell" ? [] : historyComments()) - if (next === currentHistory.entries) return - setCurrentHistory("entries", next) + history.add(prompt, mode, mode === "shell" ? [] : historyComments()) } createEffect( @@ -1078,7 +1129,7 @@ export const PromptInput: Component = (props) => { } setStore("mode", "normal") - setStore("popover", null) + closePopover() setStore("historyIndex", -1) setStore("savedPrompt", null) prompt.set(edit.prompt, promptLength(edit.prompt)) @@ -1096,7 +1147,7 @@ export const PromptInput: Component = (props) => { const navigateHistory = (direction: "up" | "down") => { const result = navigatePromptHistory({ direction, - entries: store.mode === "shell" ? shellHistory.entries : history.entries, + entries: history.entries(store.mode), historyIndex: store.historyIndex, currentPrompt: prompt.current(), currentComments: historyComments(), @@ -1110,6 +1161,7 @@ export const PromptInput: Component = (props) => { } const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({ + prompt, editor: () => editorRef, isDialogActive: () => !!dialog.active, setDraggingType: (type) => setStore("draggingType", type), @@ -1119,6 +1171,7 @@ export const PromptInput: Component = (props) => { }, addPart, readClipboardImage: platform.readClipboardImage, + getPathForFile: platform.getPathForFile, }) const fileAttachmentInput = () => ( @@ -1136,38 +1189,45 @@ export const PromptInput: Component = (props) => { /> ) - const variants = createMemo(() => ["default", ...local.model.variant.list()]) + const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()]) // Check provider variants directly: `variants` also includes the UI-only default option. - const showVariantControl = createMemo(() => local.model.variant.list().length > 0) + const showVariantControl = createMemo(() => props.controls.model.selection.variant.list().length > 0) const accepting = createMemo(() => { - const id = params.id - if (!id) return permission.isAutoAcceptingDirectory(sdk.directory) - return permission.isAutoAccepting(id, sdk.directory) + const id = props.controls.session.id + if (!id) return permission.isAutoAcceptingDirectory(sdk().directory) + return permission.isAutoAccepting(id, sdk().directory) }) - const { abort, handleSubmit } = createPromptSubmit({ - info, - imageAttachments, - commentCount, - autoAccept: () => accepting(), - mode: () => store.mode, - working, - editor: () => editorRef, - queueScroll, - promptLength, - addToHistory, - resetHistoryNavigation: () => { - resetHistoryNavigation(true) - }, - setMode: (mode) => setStore("mode", mode), - setPopover: (popover) => setStore("popover", popover), - newSessionWorktree: () => props.newSessionWorktree, - onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, - shouldQueue: props.shouldQueue, - onQueue: props.onQueue, - onAbort: props.onAbort, - onSubmit: props.onSubmit, - }) + const { abort, handleSubmit } = + props.submission ?? + createPromptSubmit({ + prompt, + info, + imageAttachments, + commentCount, + autoAccept: () => accepting(), + mode: () => store.mode, + working, + editor: () => editorRef, + queueScroll, + promptLength, + addToHistory, + resetHistoryNavigation: () => { + resetHistoryNavigation(true) + }, + setMode: (mode) => setStore("mode", mode), + setPopover: (popover) => { + if (!popover) return closePopover() + setStore({ popover, slashMenu: false, slashMenuQuery: "" }) + }, + newSessionWorktree: () => props.newSessionWorktree, + onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, + shouldQueue: props.shouldQueue, + onQueue: props.onQueue, + onAbort: props.onAbort, + onSubmit: props.onSubmit, + model: props.controls.model.selection, + }) const handleKeyDown = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") { @@ -1199,7 +1259,7 @@ export const PromptInput: Component = (props) => { const cursorPosition = getCursorPosition(editorRef) if (cursorPosition === 0) { setStore("mode", "shell") - setStore("popover", null) + closePopover() event.preventDefault() return } @@ -1274,6 +1334,9 @@ export const PromptInput: Component = (props) => { } if (store.popover === "slash") { slashOnKeyDown(event) + if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) { + scrollSlashActiveIntoView() + } } event.preventDefault() return @@ -1331,145 +1394,44 @@ export const PromptInput: Component = (props) => { } } - const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({ - queries: [ - queryOptions.agents(pathKey(sdk.directory)), - queryOptions.providers(null), - queryOptions.providers(pathKey(sdk.directory)), - ], - })) + const handleSlashMenuKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + closePopover() + requestAnimationFrame(() => editorRef.focus()) + event.preventDefault() + return + } + + if (event.key === "Tab") { + selectPopoverActive() + event.preventDefault() + return + } + + const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey + const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter" + const ctrlNav = ctrl && (event.key === "n" || event.key === "p") + if (!nav && !ctrlNav) return + slashOnKeyDown(event) + if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) scrollSlashActiveIntoView() + event.preventDefault() + } - const agentsLoading = () => agentsQuery.isLoading - const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading()) - const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading - const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading()) + const agentsLoading = () => props.controls.agents.loading + const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading()) + const providersLoading = () => props.controls.model.loading + const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading()) const [promptReady] = createResource( - () => prompt.ready().promise, + () => prompt.ready.promise, (p) => p, ) - const designPlaceholder = () => { - if (store.mode === "shell") return placeholder() - return "Ask anything, / for commands, @ for context..." - } - - const modelControlState = createMemo(() => ({ - loading: providersLoading(), - paid: providers.paid().length > 0, - title: language.t("command.model.choose"), - keybind: command.keybind("model.choose"), - model: local.model, - providerID: local.model.current()?.provider?.id, - modelName: local.model.current()?.name ?? language.t("dialog.model.select.title"), - style: control(), - onClose: restoreFocus, - onUnpaidClick: () => { - void import("@/components/dialog-select-model-unpaid").then((x) => { - dialog.show(() => ) - }) - }, - })) - - const newSession = () => props.variant === "new-session" - const projects = createMemo(() => layout.projects.list()) - const projectForDirectory = (directory: string | undefined) => { - if (!directory) return - const key = pathKey(directory) - return projects().find( - (project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key), - ) + const bindEditorRef = (el: HTMLDivElement) => { + editorRef = el + restoreEndOnFocus = true + props.ref?.(el) } - const selectedProject = createMemo(() => projectForDirectory(sdk.directory)) - const projectResults = createMemo(() => { - const search = picker.projectSearch.trim().toLowerCase() - if (!search) return projects() - return projects().filter((project) => displayName(project).toLowerCase().includes(search)) - }) - const showAgentControl = createMemo(() => settings.general.showCustomAgents() && agentNames().length > 0) - const selectProject = (worktree: string) => { - setPicker({ - projectOpen: false, - projectSearch: "", - }) - if (pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "")) { - restoreFocus() - return - } - layout.projects.open(worktree) - server.projects.touch(worktree) - navigate(`/${base64Encode(worktree)}/session`) - } - const addProject = () => { - const conn = server.current - if (!conn) return - const select = (result: string | string[] | null) => { - const directory = Array.isArray(result) ? result[0] : result - if (!directory) return - selectProject(directory) - } - pickDirectory({ - server: conn, - title: language.t("command.project.open"), - onSelect: select, - }) - } - - const projectPickerState = createMemo(() => ({ - open: picker.projectOpen, - trigger: { - action: "prompt-project", - icon: "folder", - label: selectedProject() ? displayName(selectedProject()!) : language.t("session.new.project.new"), - class: "max-w-[203px]", - style: control(), - onPress: () => setPicker("projectOpen", true), - }, - search: picker.projectSearch, - searchPlaceholder: language.t("session.new.project.search"), - clearLabel: language.t("common.clear"), - items: projectResults().map((project) => ({ - icon: "folder", - label: displayName(project), - selected: selectedProject()?.worktree === project.worktree, - onSelect: () => selectProject(project.worktree), - })), - action: { - icon: "plus", - label: language.t("session.new.project.add"), - onSelect: () => { - setPicker("projectOpen", false) - void addProject() - }, - }, - onOpenChange: (open) => { - setPicker("projectOpen", open) - if (open) requestAnimationFrame(() => projectSearchRef?.focus()) - }, - onSearchInput: (value) => setPicker("projectSearch", value), - onSearchClear: () => setPicker("projectSearch", ""), - searchRef: (el) => (projectSearchRef = el), - })) - const agentControlState = createMemo(() => ({ - title: language.t("command.agent.cycle"), - keybind: command.keybind("agent.cycle"), - options: agentNames(), - current: local.agent.current()?.name ?? "", - style: control(), - onSelect: (value) => { - local.agent.set(value) - restoreFocus() - }, - })) - const newProjectTriggerState = createMemo(() => ({ - action: "prompt-project", - icon: "folder-add-left", - label: language.t("session.new.project.new"), - class: "max-w-[160px]", - style: control(), - onPress: () => void addProject(), - })) - return (
{(promptReady(), null)} @@ -1485,738 +1447,347 @@ export const PromptInput: Component = (props) => { slashActive={slashActive() ?? undefined} setSlashActive={setSlashActive} onSlashSelect={handleSlashSelect} + slashMenu={store.slashMenu} + slashMenuQuery={store.slashMenuQuery} + onSlashMenuInput={(value) => { + setStore("slashMenuQuery", value) + slashOnInput(value) + }} + onSlashMenuKeyDown={handleSlashMenuKeyDown} commandKeybind={command.keybind} + commandKeybindParts={command.keybindParts} + newLayoutDesigns={false} t={(key) => language.t(key as Parameters[0])} /> - - -
- + + { + 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) + }} + newLayoutDesigns={false} + t={(key) => language.t(key as Parameters[0])} + /> + + dialog.show(() => ) + } + onRemove={removeAttachment} + removeLabel={language.t("prompt.attachment.remove")} + fileLabel={language.t("ui.common.file")} + newLayoutDesigns={false} + /> +
{ + const target = e.target + if (!(target instanceof HTMLElement)) return + if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) { + return + } + editorRef?.focus() + }} + > +
(scrollRef = el)} + style={{ "scroll-padding-bottom": space }} + > +
+
- - { - 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={removeAttachment} - removeLabel={language.t("prompt.attachment.remove")} - /> -
{ - const target = e.target - if (!(target instanceof HTMLElement)) return - if (target.closest('[data-action^="prompt-"]')) return - editorRef?.focus() - }} - > -
(scrollRef = el)}> -
{ - editorRef = el - props.ref?.(el) - }} - role="textbox" - aria-multiline="true" - aria-label={designPlaceholder()} - contenteditable="true" - autocapitalize={store.mode === "normal" ? "sentences" : "off"} - autocorrect={store.mode === "normal" ? "on" : "off"} - spellcheck={store.mode === "normal"} - inputMode="text" - // @ts-expect-error - autocomplete="off" - onInput={handleInput} - onPaste={handlePaste} - onCompositionStart={handleCompositionStart} - onCompositionEnd={handleCompositionEnd} - onBlur={handleBlur} - onKeyDown={handleKeyDown} - classList={{ - "select-text": true, - "min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-base": true, - "[&_[data-type=file]]:text-syntax-property": true, - "[&_[data-type=agent]]:text-syntax-type": true, - "font-mono!": store.mode === "shell", - }} - /> -
- {designPlaceholder()} -
-
-
-
-
- {fileAttachmentInput()} - - - - - - - - - - - -
- - { + const list = e.currentTarget.files + if (list) void addAttachments(Array.from(list)) + e.currentTarget.value = "" }} - t={(key) => language.t(key as Parameters[0])} - /> - - dialog.show(() => ) - } - onRemove={removeAttachment} - removeLabel={language.t("prompt.attachment.remove")} /> + +
+ + + +
+
+ +
{ - const target = e.target - if (!(target instanceof HTMLElement)) return - if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) { - return - } - editorRef?.focus() + aria-hidden={store.mode !== "normal"} + class="pointer-events-auto" + style={{ + "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none", }} > -
(scrollRef = el)} - style={{ "scroll-padding-bottom": space }} + -
{ - editorRef = el - props.ref?.(el) - }} - role="textbox" - aria-multiline="true" - aria-label={placeholder()} - contenteditable="true" - autocapitalize={store.mode === "normal" ? "sentences" : "off"} - autocorrect={store.mode === "normal" ? "on" : "off"} - spellcheck={store.mode === "normal"} - inputMode="text" - // @ts-expect-error - autocomplete="off" - onInput={handleInput} - onPaste={handlePaste} - onCompositionStart={handleCompositionStart} - onCompositionEnd={handleCompositionEnd} - onBlur={handleBlur} - onKeyDown={handleKeyDown} - classList={{ - "select-text": true, - "w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, - "[&_[data-type=file]]:text-syntax-property": true, - "[&_[data-type=agent]]:text-syntax-type": true, - "font-mono!": store.mode === "shell", - }} - style={{ "padding-bottom": space }} - /> -
- {placeholder()} -
-
- + + +
+
+
+
+ + + +
+
- -
- settings.general.setShowTerminal(checked)} - /> -
-
- {
) - const AppearanceSection = () => ( -
-

{language.t("settings.general.section.appearance")}

- - - - o.value === theme.colorScheme())} - placement="bottom-end" - gutter={6} - value={(o) => o.value} - label={(o) => o.label} - onSelect={(option) => option && theme.setColorScheme(option.value)} - onHighlight={(option) => { - if (!option) return - theme.previewColorScheme(option.value) - return () => theme.cancelPreview() - }} - /> - - - - {language.t("settings.general.row.theme.description")}{" "} - - {language.t("common.learnMore")} - - - } - > - o.id === theme.themeId())} - placement="bottom-end" - gutter={6} - value={(o) => o.id} - label={(o) => o.name} - onSelect={(option) => { - if (!option) return - theme.setTheme(option.id) - }} - onHighlight={(option) => { - if (!option) return - theme.previewTheme(option.id) - return () => theme.cancelPreview() - }} - /> - - - -
- settings.appearance.setUIFont(event.currentTarget.value)} - placeholder={sansDefault} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - aria-label={language.t("settings.general.row.uiFont.title")} - style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }} - /> -
-
- - -
- settings.appearance.setFont(event.currentTarget.value)} - placeholder={monoDefault} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - aria-label={language.t("settings.general.row.font.title")} - style={{ "font-family": monoFontFamily(settings.appearance.font()) }} - /> -
-
- - -
- settings.appearance.setTerminalFont(event.currentTarget.value)} - placeholder={terminalDefault} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - aria-label={language.t("settings.general.row.terminalFont.title")} - style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }} - /> -
-
-
-
- ) - const NotificationsSection = () => (

{language.t("settings.general.section.notifications")}

@@ -618,68 +486,6 @@ export const SettingsGeneralV2: Component = () => {
) - const SoundsSection = () => ( -
-

{language.t("settings.general.section.sounds")}

- - - - settings.sounds.agentEnabled(), - () => settings.sounds.agent(), - (value) => settings.sounds.setAgentEnabled(value), - (id) => settings.sounds.setAgent(id), - )} - placement="bottom-end" - gutter={6} - /> - - - - settings.sounds.permissionsEnabled(), - () => settings.sounds.permissions(), - (value) => settings.sounds.setPermissionsEnabled(value), - (id) => settings.sounds.setPermissions(id), - )} - placement="bottom-end" - gutter={6} - /> - - - - settings.sounds.errorsEnabled(), - () => settings.sounds.errors(), - (value) => settings.sounds.setErrorsEnabled(value), - (id) => settings.sounds.setErrors(id), - )} - placement="bottom-end" - gutter={6} - /> - - -
- ) - const UpdatesSection = () => (

{language.t("settings.general.section.updates")}

@@ -701,7 +507,7 @@ export const SettingsGeneralV2: Component = () => { title={language.t("settings.updates.row.check.title")} description={language.t("settings.updates.row.check.description")} > - + updater.run()}> {language.t(updater.action().label)} @@ -709,6 +515,7 @@ export const SettingsGeneralV2: Component = () => {
) + // We can probably remove this, right? const DisplaySection = () => (
@@ -723,26 +530,6 @@ export const SettingsGeneralV2: Component = () => {
- - - - {language.t("settings.general.row.wayland.title")} - - - - - -
- } - description={language.t("settings.general.row.wayland.description")} - > -
- -
- -
@@ -755,21 +542,29 @@ export const SettingsGeneralV2: Component = () => {
+ + + + + + + + - + - + - + + + - - - +
) diff --git a/packages/app/src/components/settings-v2/interface-transition.stories.tsx b/packages/app/src/components/settings-v2/interface-transition.stories.tsx new file mode 100644 index 000000000000..0a48de957254 --- /dev/null +++ b/packages/app/src/components/settings-v2/interface-transition.stories.tsx @@ -0,0 +1,76 @@ +// @ts-nocheck +import { Show } from "solid-js" +import { createStore } from "solid-js/store" +import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition" + +const copy = { + title: "New layout", + badge: "New", + description: "Use the new tabs and home layout. Switch between layouts for a limited time.", + noticeTitle: "You're now using new layout", + noticeDescription: "The previous layout is no longer available", + dismiss: "Dismiss", +} + +function Frame(props) { + return
{props.children}
+} + +function ToggleExample(props) { + const [state, setState] = createStore({ checked: props.checked }) + return ( + + setState("checked", checked)} + /> + + ) +} + +function NoticeExample() { + const [state, setState] = createStore({ dismissed: false }) + return ( + + Notice dismissed}> + setState("dismissed", true)} + /> + + + ) +} + +export default { + title: "App/Settings/Layout transition", + id: "app-settings-layout-transition", + component: LayoutTransitionToggle, +} + +export const NewLayoutEnabled = { + render: () => , +} + +export const PreviousLayoutEnabled = { + render: () => , +} + +export const PreviousLayoutRetired = { + render: () => , +} + +export const AllStates = { + render: () => ( +
+ + + +
+ ), +} diff --git a/packages/app/src/components/settings-v2/interface-transition.tsx b/packages/app/src/components/settings-v2/interface-transition.tsx new file mode 100644 index 000000000000..53bd980a5401 --- /dev/null +++ b/packages/app/src/components/settings-v2/interface-transition.tsx @@ -0,0 +1,54 @@ +import { Tag } from "@opencode-ai/ui/v2/badge-v2" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Switch } from "@opencode-ai/ui/v2/switch-v2" +import { SettingsListV2 } from "./parts/list" +import { SettingsRowV2 } from "./parts/row" + +export function LayoutTransitionToggle(props: { + title: string + badge: string + description: string + checked: boolean + onChange: (checked: boolean) => void +}) { + return ( +
+
+ + + {props.title} + {props.badge} + + } + description={props.description} + > +
+ +
+
+
+
+
+ ) +} + +export function LayoutRetirementNotice(props: { + title: string + description: string + dismiss: string + onDismiss: () => void +}) { + return ( +
+ + + + {props.dismiss} + + + +
+ ) +} diff --git a/packages/app/src/components/settings-v2/models.tsx b/packages/app/src/components/settings-v2/models.tsx index a3f058670e4a..482e8cbffbd4 100644 --- a/packages/app/src/components/settings-v2/models.tsx +++ b/packages/app/src/components/settings-v2/models.tsx @@ -5,9 +5,12 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { type Component, For, Show } from "solid-js" +import { createStore } from "solid-js/store" import { useLanguage } from "@/context/language" import { useModels } from "@/context/models" +import { useServerSDK } from "@/context/server-sdk" import { popularProviders } from "@/hooks/use-providers" +import { Persist, persisted } from "@/utils/persist" import { SettingsListV2 } from "./parts/list" import { SettingsRowV2 } from "./parts/row" import "./settings-v2.css" @@ -19,6 +22,11 @@ const PROVIDER_ICON_SIZE = 16 export const SettingsModelsV2: Component = () => { const language = useLanguage() const models = useModels() + const serverSdk = useServerSDK() + const [store, setStore] = persisted( + Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"), + createStore({ collapsed: {} as Record }), + ) const list = useFilteredList({ items: (_filter) => models.list(), @@ -94,41 +102,82 @@ export const SettingsModelsV2: Component = () => { } > - {(group) => ( -
-
- -

{group.items[0].provider.name}

+ {(group) => { + const searching = () => list.filter().length > 0 + const expanded = () => searching() || !store.collapsed[group.category] + + return ( +
+

+ +

+ + + + {(item) => { + const key = { providerID: item.provider.id, modelID: item.id } + return ( + +
+ { + models.setVisibility(key, checked) + }} + hideLabel + > + {item.name} + +
+
+ ) + }} +
+
+
- - - {(item) => { - const key = { providerID: item.provider.id, modelID: item.id } - return ( - -
- { - models.setVisibility(key, checked) - }} - hideLabel - > - {item.name} - -
-
- ) - }} -
-
-
- )} + ) + }} diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx index f5ad6e9762ba..acd73ddbe544 100644 --- a/packages/app/src/components/settings-v2/providers.tsx +++ b/packages/app/src/components/settings-v2/providers.tsx @@ -4,12 +4,11 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { showToast } from "@/utils/toast" import { popularProviders, useProviders } from "@/hooks/use-providers" -import { createMemo, type Component, For, Show } from "solid-js" +import { createMemo, type Accessor, type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" -import { useServerSDK } from "@/context/server-sdk" +import { useServerProtocol, useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" -import { DialogConnectProvider } from "../dialog-connect-provider" -import { DialogSelectProvider } from "../dialog-select-provider" +import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider" import { DialogCustomProvider } from "../dialog-custom-provider" import { SettingsListV2 } from "./parts/list" import "./settings-v2.css" @@ -30,12 +29,22 @@ const PROVIDER_NOTES = [ const PROVIDER_ICON_SIZE = 16 -export const SettingsProvidersV2: Component = () => { +export const SettingsProvidersV2: Component<{ + directory: Accessor + onBack?: () => void +}> = (props) => { const dialog = useDialog() const language = useLanguage() const serverSdk = useServerSDK() + const protocol = useServerProtocol() const serverSync = useServerSync() - const providers = useProviders() + const providers = useProviders(props.directory) + const providerConnect = useProviderConnectController({ onBack: props.onBack }) + + const connect = (provider?: string) => { + providerConnect.select(provider) + void dialog.show(() => ) + } const connected = createMemo(() => { return providers @@ -72,12 +81,13 @@ export const SettingsProvidersV2: Component = () => { return language.t("settings.providers.tag.other") } - const canDisconnect = (item: ProviderItem) => source(item) !== "env" + const canDisconnect = (item: ProviderItem) => + source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id)) const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key const isConfigCustom = (providerID: string) => { - const provider = serverSync.data.config.provider?.[providerID] + const provider = serverSync().data.config.provider?.[providerID] if (!provider) return false if (provider.npm !== "@ai-sdk/openai-compatible") return false if (!provider.models || Object.keys(provider.models).length === 0) return false @@ -85,11 +95,12 @@ export const SettingsProvidersV2: Component = () => { } const disableProvider = async (providerID: string, name: string) => { - const before = serverSync.data.config.disabled_providers ?? [] + if (protocol() !== "v1") return + const before = serverSync().data.config.disabled_providers ?? [] const next = before.includes(providerID) ? before : [...before, providerID] - serverSync.set("config", "disabled_providers", next) + serverSync().set("config", "disabled_providers", next) - await serverSync + await serverSync() .updateConfig({ disabled_providers: next }) .then(() => { showToast({ @@ -100,7 +111,7 @@ export const SettingsProvidersV2: Component = () => { }) }) .catch((err: unknown) => { - serverSync.set("config", "disabled_providers", before) + serverSync().set("config", "disabled_providers", before) const message = err instanceof Error ? err.message : String(err) showToast({ title: language.t("common.requestFailed"), description: message }) }) @@ -108,14 +119,16 @@ export const SettingsProvidersV2: Component = () => { const disconnect = async (providerID: string, name: string) => { if (isConfigCustom(providerID)) { - await serverSdk.client.auth.remove({ providerID }).catch(() => undefined) + await serverSdk() + .client.auth.remove({ providerID }) + .catch(() => undefined) await disableProvider(providerID, name) return } - await serverSdk.client.auth - .remove({ providerID }) + await serverSdk() + .client.auth.remove({ providerID }) .then(async () => { - await serverSdk.client.global.dispose() + await serverSdk().client.global.dispose() showToast({ variant: "success", icon: "circle-check", @@ -204,56 +217,47 @@ export const SettingsProvidersV2: Component = () => {
- { - dialog.show(() => ) - }} - > + connect(item.id)}> {language.t("common.connect")}
)} -
-
- -
-
- {language.t("provider.custom.title")} - {language.t("settings.providers.tag.custom")} + +
+
+ +
+
+ {language.t("provider.custom.title")} + {language.t("settings.providers.tag.custom")} +
+

+ {language.t("settings.providers.custom.description")} +

-

{language.t("settings.providers.custom.description")}

+ { + dialog.show(() => ) + }} + > + {language.t("common.connect")} +
- { - dialog.show(() => ) - }} - > - {language.t("common.connect")} - -
+ -
diff --git a/packages/app/src/components/settings-v2/servers.tsx b/packages/app/src/components/settings-v2/servers.tsx index 3a7203bc7647..b93f700d24cf 100644 --- a/packages/app/src/components/settings-v2/servers.tsx +++ b/packages/app/src/components/settings-v2/servers.tsx @@ -1,4 +1,3 @@ -import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { Tag } from "@opencode-ai/ui/v2/badge-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" @@ -14,7 +13,7 @@ import { ServerConnection, serverName } from "@/context/server" import { useServerManagementController } from "../dialog-select-server" import { DialogServerV2 } from "./dialog-server-v2" import { SettingsListV2 } from "./parts/list" -import { isWslServer, useFilteredWslServers, WslAddServerButton, WslServerSettings } from "@/wsl/settings" +import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings" import "./settings-v2.css" export const SettingsServersV2: Component = () => { @@ -55,10 +54,7 @@ export const SettingsServersV2: Component = () => { >

{language.t("status.popover.tab.servers")}

- - {language.t("dialog.server.add.button")} - - +
- -
-
- 0} - fallback={
{pluginEmpty()}
} - > - - {(plugin) => ( -
-
- {plugin} -
- )} - - + + +
+
+ 0} + fallback={
{pluginEmpty()}
} + > + + {(plugin) => ( +
+
+ {plugin} +
+ )} + + +
-
-
+ +
) diff --git a/packages/app/src/components/status-popover-indicator.test.ts b/packages/app/src/components/status-popover-indicator.test.ts new file mode 100644 index 000000000000..d6d6d07a1d81 --- /dev/null +++ b/packages/app/src/components/status-popover-indicator.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test" +import { + hasNonBlockingServiceIssue, + hasServiceNeedingAttention, + serverStatusDotClass, +} from "./status-popover-indicator" + +describe("serverStatusDotClass", () => { + test("uses the success token while the server and services are healthy", () => { + expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base") + }) + + test("uses the session attention token when a service needs attention", () => { + expect(serverStatusDotClass({ ready: true, serverHealth: true, attention: true, issue: true })).toBe( + "bg-v2-background-bg-accent", + ) + }) + + test("uses the warning token for non-blocking issues while the server is online", () => { + expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base") + }) + + test("uses the critical token only after the server connection drops", () => { + expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false })).toBe("bg-icon-critical-base") + expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: true })).toBe("bg-icon-critical-base") + }) + + test("stays neutral before status is ready", () => { + expect(serverStatusDotClass({ ready: false, serverHealth: true, issue: false })).toBe("bg-border-weak-base") + expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false })).toBe("bg-border-weak-base") + }) +}) + +describe("hasNonBlockingServiceIssue", () => { + test("detects MCP failures that do not block chatting", () => { + expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true) + expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true) + expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true) + expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false) + }) + + test("detects LSP failures that do not block chatting", () => { + expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["error"] })).toBe(true) + expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false) + }) +}) + +describe("hasServiceNeedingAttention", () => { + test("detects MCP states that need user attention", () => { + expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true) + expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true) + }) + + test("ignores states that do not need user attention", () => { + expect(hasServiceNeedingAttention({ mcp: ["failed"] })).toBe(false) + expect(hasServiceNeedingAttention({ mcp: ["connected", "pending", "disabled"] })).toBe(false) + }) +}) diff --git a/packages/app/src/components/status-popover-indicator.ts b/packages/app/src/components/status-popover-indicator.ts new file mode 100644 index 000000000000..62fc3bedbff3 --- /dev/null +++ b/packages/app/src/components/status-popover-indicator.ts @@ -0,0 +1,30 @@ +import type { LspStatus } from "@opencode-ai/sdk/v2/client" +import type { McpServer } from "@opencode-ai/client/promise" + +export function hasServiceNeedingAttention(input: { mcp: Array }) { + return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration") +} + +export function hasNonBlockingServiceIssue(input: { + mcp: Array + lsp: Array +}) { + return ( + input.mcp.some((status) => status !== "connected" && status !== "pending" && status !== "disabled") || + input.lsp.some((status) => status === "error") + ) +} + +export function serverStatusDotClass(input: { + ready: boolean + serverHealth: boolean | undefined + attention?: boolean + issue: boolean +}) { + if (input.serverHealth === false) return "bg-icon-critical-base" + if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base" + if (input.attention) return "bg-v2-background-bg-accent" + if (input.issue) return "bg-icon-warning-base" + if (input.serverHealth === true) return "bg-icon-success-base" + return "bg-border-weak-base" +} diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 6b42e62e535a..767e7e62c6f6 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -5,9 +5,15 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Popover } from "@opencode-ai/ui/popover" import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js" import { useLanguage } from "@/context/language" -import { useServer } from "@/context/server" +import { ServerConnection, useServer } from "@/context/server" +import { useServerSDK } from "@/context/server-sdk" import { useSync } from "@/context/sync" import { useGlobal } from "@/context/global" +import { + hasNonBlockingServiceIssue, + hasServiceNeedingAttention, + serverStatusDotClass, +} from "./status-popover-indicator" const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody }))) const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody }))) @@ -18,16 +24,19 @@ export function StatusPopover() { const global = useGlobal() const sync = useSync() const [shown, setShown] = createSignal(false) - const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync.data.mcp_ready) - const mcpIssue = createMemo(() => { - const mcp = Object.values(sync.data.mcp ?? {}) - const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration") - const warn = mcp.some((item) => item.status === "needs_auth") - if (failed) return "critical" as const - if (warn) return "warning" as const - }) - const serverHealthy = () => global.servers.health[server.key]?.healthy === true - const healthy = createMemo(() => global.servers.health[server.key]?.healthy === true && !mcpIssue()) + const serverHealth = () => global.servers.health[server.key]?.healthy + const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) + const attention = createMemo(() => + hasServiceNeedingAttention({ + mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), + }), + ) + const issue = createMemo(() => + hasNonBlockingServiceIssue({ + mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), + lsp: (sync().data.lsp ?? []).map((item) => item.status), + }), + ) return (
} @@ -81,26 +89,29 @@ export function StatusPopoverV2(props: { scope?: "server" }) { function DirectoryStatusPopover() { const language = useLanguage() - const server = useServer() + const server = useServerSDK() const global = useGlobal() const sync = useSync() const [shown, setShown] = createSignal(false) - const serverHealth = () => global.servers.health[server.key]?.healthy - const ready = createMemo(() => serverHealth() === false || sync.data.mcp_ready) - const mcpIssue = createMemo(() => { - const mcp = Object.values(sync.data.mcp ?? {}) - const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration") - const warn = mcp.some((item) => item.status === "needs_auth") - if (failed) return "critical" as const - if (warn) return "warning" as const - }) - const healthy = createMemo(() => serverHealth() === true && !mcpIssue()) + const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy + const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) + const attention = createMemo(() => + hasServiceNeedingAttention({ + mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), + }), + ) + const issue = createMemo(() => + hasNonBlockingServiceIssue({ + mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), + lsp: (sync().data.lsp ?? []).map((item) => item.status), + }), + ) const state = createMemo(() => ({ shown: shown(), ready: ready(), - healthy: healthy(), serverHealth: serverHealth(), - issue: mcpIssue(), + attention: attention(), + issue: issue(), label: language.t("status.popover.trigger"), onOpenChange: setShown, body: () => ( @@ -122,8 +133,9 @@ function ServerStatusPopover() { const state = createMemo(() => ({ shown: shown(), ready: serverHealth() !== undefined, - healthy: serverHealth() === true, serverHealth: serverHealth(), + attention: false, + issue: false, label: language.t("status.popover.trigger"), onOpenChange: setShown, body: () => ( @@ -139,9 +151,9 @@ function ServerStatusPopover() { type StatusPopoverState = { shown: boolean ready: boolean - healthy: boolean serverHealth: boolean | undefined - issue?: "critical" | "warning" + attention: boolean + issue: boolean label: string onOpenChange: (value: boolean) => void body: () => JSX.Element @@ -160,16 +172,6 @@ function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) { } function StatusPopoverView(props: { state: StatusPopoverState }) { - const statusDotClass = () => ({ - "absolute rounded-full": true, - "bg-icon-success-base": props.state.ready && props.state.healthy, - "bg-icon-warning-base": props.state.ready && props.state.serverHealth === true && props.state.issue === "warning", - "bg-icon-critical-base": - props.state.serverHealth === false || - (props.state.ready && props.state.serverHealth === true && props.state.issue === "critical"), - "bg-border-weak-base": props.state.serverHealth === undefined || !props.state.ready, - }) - const popoverProps = { class: "[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl", @@ -194,8 +196,7 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
} diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 035eb3285695..0ca42b35bb1b 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -1,7 +1,8 @@ import { withAlpha } from "@opencode-ai/ui/theme/color" import { useTheme } from "@opencode-ai/ui/theme/context" import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve" -import type { HexColor } from "@opencode-ai/ui/theme/types" +import { resolveThemeVariantV2 } from "@opencode-ai/ui/theme/v2/resolve" +import type { HexColor, ResolvedV2Theme } from "@opencode-ai/ui/theme/types" import { showToast } from "@/utils/toast" import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web" import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js" @@ -10,7 +11,7 @@ import { matchKeybind, parseKeybind } from "@/context/command" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" -import { useServer } from "@/context/server" +import { useServerSDK } from "@/context/server-sdk" import { terminalFontFamily, useSettings } from "@/context/settings" import type { LocalPTY } from "@/context/terminal" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" @@ -22,6 +23,7 @@ const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`" export interface TerminalProps extends ComponentProps<"div"> { pty: LocalPTY autoFocus?: boolean + onAutoFocus?: () => void onSubmit?: () => void onCleanup?: (pty: Partial & { id: string }) => void onConnect?: () => void @@ -68,6 +70,19 @@ const debugTerminal = (...values: unknown[]) => { console.debug("[terminal]", ...values) } +const resolveV2Token = (tokens: ResolvedV2Theme, key: string) => { + let current = tokens[key] + for (let i = 0; i < 8 && current; i++) { + const match = /^var\(--([^)]+)\)$/.exec(current.trim()) + if (!match) { + const hex = current.trim() + if (/^#[0-9a-fA-F]{8}$/.test(hex)) return hex.slice(0, 7) + return hex + } + current = tokens[match[1]] + } +} + const useTerminalUiBindings = (input: { container: HTMLDivElement term: Term @@ -160,16 +175,25 @@ export const Terminal = (props: TerminalProps) => { const settings = useSettings() const theme = useTheme() const language = useLanguage() - const server = useServer() - const directory = sdk.directory - const client = sdk.client - const url = sdk.url - const auth = server.current?.http + // Terminal captures its connection for the PTY lifetime, so callers must key it per server/session. + const connection = useServerSDK()().server + const directory = sdk().directory + const url = sdk().url + const auth = connection.http const username = auth?.username ?? "opencode" const password = auth?.password ?? "" + const authToken = connection.type === "http" ? connection.authToken : false const sameOrigin = new URL(url, location.href).origin === location.origin let container!: HTMLDivElement - const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"]) + const [local, others] = splitProps(props, [ + "pty", + "class", + "classList", + "autoFocus", + "onAutoFocus", + "onConnect", + "onConnectError", + ]) const id = local.pty.id const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : "" const restoreSize = @@ -216,10 +240,21 @@ export const Terminal = (props: TerminalProps) => { } } - const pushSize = (cols: number, rows: number) => { - return client.pty - .update({ + const pushSize = async (cols: number, rows: number) => { + if ((await sdk().protocol) === "v1") { + return sdk() + .client.pty.update({ + ptyID: id, + size: { cols, rows }, + }) + .catch((err) => { + debugTerminal("failed to sync terminal size", err) + }) + } + return sdk() + .api.pty.update({ ptyID: id, + location: { directory }, size: { cols, rows }, }) .catch((err) => { @@ -236,7 +271,10 @@ export const Terminal = (props: TerminalProps) => { if (!variant?.seeds && !variant?.palette) return fallback const resolved = resolveThemeVariant(variant, mode === "dark") const text = resolved["text-stronger"] ?? fallback.foreground - const background = resolved["background-stronger"] ?? fallback.background + const background = settings.general.newLayoutDesigns() + ? (resolveV2Token(resolveThemeVariantV2(variant, mode === "dark"), "v2-background-bg-base") ?? + fallback.background) + : (resolved["background-stronger"] ?? fallback.background) const alpha = mode === "dark" ? 0.25 : 0.2 const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor) const selectionBackground = withAlpha(base, alpha) @@ -264,7 +302,12 @@ export const Terminal = (props: TerminalProps) => { const scheduleSize = (cols: number, rows: number) => { if (disposed) return - if (lastSize?.cols === cols && lastSize?.rows === rows) return + if (lastSize?.cols === cols && lastSize?.rows === rows) { + pendingSize = undefined + if (sizeTimer !== undefined) clearTimeout(sizeTimer) + sizeTimer = undefined + return + } pendingSize = { cols, rows } @@ -289,8 +332,10 @@ export const Terminal = (props: TerminalProps) => { createEffect(() => { const colors = terminalColors() + const mode = theme.mode() === "dark" ? "dark" : "light" if (!term) return setOptionIfSupported(term, "theme", colors) + setOptionIfSupported(term, "colorScheme", mode) }) createEffect(() => { @@ -337,7 +382,11 @@ export const Terminal = (props: TerminalProps) => { event.preventDefault() event.stopImmediatePropagation() - platform.openLink(text) + if (URL.canParse(text) && new URL(text).protocol === "file:" && platform.openLocalFile) { + platform.openLocalFile(text) + return + } + platform.openExternal(text) } onMount(() => { @@ -368,6 +417,7 @@ export const Terminal = (props: TerminalProps) => { } _ghostty = g term = t + setOptionIfSupported(t, "colorScheme", theme.mode() === "dark" ? "dark" : "light") output = terminalWriter((data, done) => t.write(data, () => { done?.() @@ -397,6 +447,7 @@ export const Terminal = (props: TerminalProps) => { fitAddon = fit serializeAddon = serializer + const active = document.activeElement t.open(container) useTerminalUiBindings({ container, @@ -406,7 +457,22 @@ export const Terminal = (props: TerminalProps) => { handleLinkClick, }) - if (local.autoFocus !== false) focusTerminal() + if (local.autoFocus === true) { + focusTerminal() + local.onAutoFocus?.() + } + if (local.autoFocus !== true) { + const restoreFocus = () => { + const current = document.activeElement + if (current !== container && !container.contains(current)) return + t.blur() + t.textarea?.blur() + if (active instanceof HTMLElement && active.isConnected) active.focus() + } + restoreFocus() + const timer = setTimeout(restoreFocus, 0) + cleanups.push(() => clearTimeout(timer)) + } if (typeof document !== "undefined" && document.fonts) { void document.fonts.ready.then(scheduleFit) @@ -470,34 +536,53 @@ export const Terminal = (props: TerminalProps) => { local.onConnectError?.(err) } - const gone = () => - client.pty - .get({ ptyID: id }, { throwOnError: false }) - .then((result) => result.response.status === 404) + const gone = async () => { + if ((await sdk().protocol) === "v1") { + return sdk() + .client.pty.get({ ptyID: id }, { throwOnError: false }) + .then((result) => result.response.status === 404) + .catch((err) => { + debugTerminal("failed to inspect terminal session", err) + return false + }) + } + return sdk() + .api.pty.get({ ptyID: id, location: { directory } }) + .then((result) => result.data.status === "exited") .catch((err) => { + if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true debugTerminal("failed to inspect terminal session", err) return false }) + } const connectToken = async () => { - const result = await client.pty - .connectToken( - { ptyID: id, directory }, - { - throwOnError: false, - headers: { "x-opencode-ticket": "1" }, - }, - ) - .catch((err: unknown) => { - if (err instanceof Error && err.message.includes("Request is not supported")) return - throw err - }) - if (!result) return - if (result.response.status === 200 && result.data?.ticket) return result.data.ticket - if (result.response.status === 404 || result.response.status === 405) return - if (result.response.status === 403) - throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") - throw new Error(`PTY connect ticket failed with ${result.response.status}`) + if ((await sdk().protocol) === "v1") { + const result = await sdk() + .client.pty.connectToken( + { ptyID: id, directory }, + { + throwOnError: false, + headers: { "x-opencode-ticket": "1" }, + }, + ) + .catch((err: unknown) => { + if (err instanceof Error && err.message.includes("Request is not supported")) return + throw err + }) + if (!result) return + if (result.response.status === 200 && result.data?.ticket) return result.data.ticket + if (result.response.status === 404 || result.response.status === 405) return + if (result.response.status === 403) throw new Error(language.t("terminal.connectTicket.csrfError")) + throw new Error(language.t("terminal.connectTicket.statusError", { status: result.response.status })) + } + // return sdk() + // .api.pty.connectToken({ + // ptyID: id, + // location: { directory }, + // "x-opencode-ticket": "1", + // }) + // .then((result) => result.data.ticket) } const retry = (err: unknown) => { @@ -527,11 +612,14 @@ export const Terminal = (props: TerminalProps) => { fail(err) return undefined }) + const protocol = await sdk().protocol + // if (protocol === "v2" && !ticket) return if (once.value) return if (disposed) return const socket = new WebSocket( terminalWebSocketURL({ + protocol, url, id, directory, @@ -540,7 +628,7 @@ export const Terminal = (props: TerminalProps) => { sameOrigin, username, password, - authToken: server.current?.type === "http" ? server.current.authToken : false, + authToken, }), ) socket.binaryType = "arraybuffer" @@ -551,6 +639,7 @@ export const Terminal = (props: TerminalProps) => { tries = 0 local.onConnect?.() scheduleSize(t.cols, t.rows) + if (t.getMode(2031)) t.write("\x1b[?996n") } const handleMessage = (event: MessageEvent) => { @@ -652,6 +741,7 @@ export const Terminal = (props: TerminalProps) => {
{ test("reads valid removed session tab details", () => { expect( readSessionTabsRemovedDetail( new CustomEvent(SESSION_TABS_REMOVED_EVENT, { - detail: { directory: "/tmp/project", sessionIDs: ["ses_1", "ses_2", 1] }, + detail: { server: "remote", directory: "/tmp/project", sessionIDs: ["ses_1", "ses_2", 1] }, }), ), ).toEqual({ + server: remote, directory: "/tmp/project", sessionIDs: ["ses_1", "ses_2"], }) diff --git a/packages/app/src/components/titlebar-session-events.ts b/packages/app/src/components/titlebar-session-events.ts index aa6b5e1d1150..7d914d7c71be 100644 --- a/packages/app/src/components/titlebar-session-events.ts +++ b/packages/app/src/components/titlebar-session-events.ts @@ -1,6 +1,9 @@ +import type { ServerConnection } from "@/context/server" + export const SESSION_TABS_REMOVED_EVENT = "opencode:session-tabs-removed" export type SessionTabsRemovedDetail = { + server?: ServerConnection.Key directory: string sessionIDs: string[] } @@ -18,11 +21,14 @@ export function readSessionTabsRemovedDetail(event: Event): SessionTabsRemovedDe if (!("sessionIDs" in detail)) return undefined if (typeof detail.directory !== "string") return undefined if (!Array.isArray(detail.sessionIDs)) return undefined + if ("server" in detail && detail.server !== undefined && typeof detail.server !== "string") return undefined const sessionIDs = detail.sessionIDs.filter((id): id is string => typeof id === "string") if (sessionIDs.length === 0) return undefined return { + server: + "server" in detail && typeof detail.server === "string" ? (detail.server as ServerConnection.Key) : undefined, directory: detail.directory, sessionIDs, } diff --git a/packages/app/src/components/titlebar-tab-gesture.test.ts b/packages/app/src/components/titlebar-tab-gesture.test.ts new file mode 100644 index 000000000000..a6ab89924872 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-gesture.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test" +import { canOpenTabRename, canStartTabDrag, forwardTabRef, isTabCloseTarget } from "./titlebar-tab-gesture" + +describe("titlebar tab gestures", () => { + test("excludes close controls from tab gestures", () => { + const close = document.createElement("div") + const button = document.createElement("button") + const link = document.createElement("a") + close.dataset.slot = "tab-close" + close.append(button) + expect(isTabCloseTarget(close)).toBe(true) + expect(isTabCloseTarget(button)).toBe(true) + expect(isTabCloseTarget(link)).toBe(false) + }) + + test("forwards component refs", () => { + const element = document.createElement("div") + let received: HTMLDivElement | undefined + forwardTabRef((value) => (received = value), element) + expect(received).toBe(element) + }) + + test("does not reopen rename while a save is pending", () => { + expect(canOpenTabRename(false, false, false)).toBe(true) + expect(canOpenTabRename(false, false, true)).toBe(false) + }) + + test("preserves native panning for touch pointers", () => { + expect(canStartTabDrag("mouse")).toBe(true) + expect(canStartTabDrag("pen")).toBe(true) + expect(canStartTabDrag("touch")).toBe(false) + }) +}) diff --git a/packages/app/src/components/titlebar-tab-gesture.ts b/packages/app/src/components/titlebar-tab-gesture.ts new file mode 100644 index 000000000000..0aa51189363d --- /dev/null +++ b/packages/app/src/components/titlebar-tab-gesture.ts @@ -0,0 +1,17 @@ +import type { Ref } from "solid-js" + +export function isTabCloseTarget(target: EventTarget | null) { + return target instanceof Element && !!target.closest('[data-slot="tab-close"]') +} + +export function canStartTabDrag(pointerType: string) { + return pointerType !== "touch" +} + +export function forwardTabRef(ref: Ref | undefined, element: HTMLDivElement) { + if (typeof ref === "function") ref(element) +} + +export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, pending: boolean) { + return !dragging && !editing && !pending +} diff --git a/packages/app/src/components/titlebar-tab-nav.css b/packages/app/src/components/titlebar-tab-nav.css new file mode 100644 index 000000000000..93834b5b968d --- /dev/null +++ b/packages/app/src/components/titlebar-tab-nav.css @@ -0,0 +1,150 @@ +[data-titlebar-tab] [data-slot="tab-close"] { + position: absolute; + top: 4px; + inset-inline-end: 4px; + display: flex; + height: 20px; + width: 20px; + align-items: center; + justify-content: center; +} + +[data-titlebar-tab] { + --tab-base: var(--v2-background-bg-deep); + --tab-overlay: transparent; + background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base); +} + +[data-titlebar-tab]:is(:hover, :has(> [data-slot="tab-link"]:focus-visible)):not([data-state="pressed"]):not( + [data-dragging="true"] + ):not([data-editing="true"]) { + --tab-base: var(--v2-background-bg-layer-02); + --tab-overlay: var(--v2-overlay-simple-overlay-hover); +} + +[data-titlebar-tab]:is([data-state="pressed"], [data-dragging="true"], [data-editing="true"]) { + --tab-base: var(--v2-background-bg-layer-02); + --tab-overlay: var(--v2-overlay-simple-overlay-pressed); +} + +[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] { + background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base); +} + +[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] { + display: none; +} + +[data-titlebar-tab-list] { + gap: 6px; +} + +[data-titlebar-tab-slot] { + --tab-separator: var(--v2-background-bg-layer-03); + position: relative; +} + +[data-color-scheme="dark"] [data-titlebar-tab-slot] { + --tab-separator: var(--v2-background-bg-layer-02); +} + +[data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before { + content: ""; + position: absolute; + top: 8px; + inset-inline-start: -3.75px; + width: 1.5px; + height: 12px; + border-radius: 9999px; + background: var(--tab-separator); +} + +[data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before { + display: none; +} + +[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover)::before, +[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover) + [data-titlebar-tab-slot]::before { + display: none; +} + +[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] { + --tab-title-fade-offset: 4px; + -webkit-mask-image: linear-gradient( + to right, + black 0, + black calc(100% - var(--tab-title-fade-offset) - 16px), + transparent calc(100% - var(--tab-title-fade-offset)), + transparent 100% + ); + mask-image: linear-gradient( + to right, + black 0, + black calc(100% - var(--tab-title-fade-offset) - 16px), + transparent calc(100% - var(--tab-title-fade-offset)), + transparent 100% + ); +} + +[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] { + -webkit-mask-image: linear-gradient( + to left, + black 0, + black calc(100% - var(--tab-title-fade-offset) - 16px), + transparent calc(100% - var(--tab-title-fade-offset)), + transparent 100% + ); + mask-image: linear-gradient( + to left, + black 0, + black calc(100% - var(--tab-title-fade-offset) - 16px), + transparent calc(100% - var(--tab-title-fade-offset)), + transparent 100% + ); +} + +[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"]) + [data-slot="tab-link"] { + --tab-title-fade-offset: 24px; +} + +[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"]) + [data-slot="tab-link"] { + padding-inline-end: 0; +} + +[data-titlebar-tab] [data-slot="tab-title"] { + min-width: 0; +} + +[data-titlebar-tab][data-editing="true"] [data-slot="tab-title"] { + min-width: 0; + max-width: 100%; + flex: 1 1 auto; + overflow-x: auto; + overflow-y: hidden; + white-space: nowrap; + scrollbar-width: none; +} + +[data-titlebar-tab][data-editing="true"] [data-slot="tab-title"]::-webkit-scrollbar { + display: none; +} + +@container (max-width: 64px) { + [data-titlebar-tab-link] { + justify-content: center; + gap: 0; + padding-inline: 0; + } + + [data-titlebar-tab-title] { + display: none; + } + + [data-slot="tab-close"] { + right: auto; + left: 50%; + transform: translateX(-50%); + } +} diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx new file mode 100644 index 000000000000..65493c54dff5 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -0,0 +1,407 @@ +import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js" +import { makeEventListener } from "@solid-primitives/event-listener" +import { createResizeObserver } from "@solid-primitives/resize-observer" +import { createMutation } from "@tanstack/solid-query" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { useGlobal } from "@/context/global" +import { useLanguage } from "@/context/language" +import { ServerConnection, serverName } from "@/context/server" +import { displayName, projectForSession } from "@/pages/layout/helpers" +import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" +import type { Session } from "@opencode-ai/sdk/v2" +import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture" +import { TabPreviewPopover } from "./titlebar-tab-popover" +import "./titlebar-tab-nav.css" + +// MouseEvent.button uses 1 for the middle/wheel button. +const MIDDLE_MOUSE_BUTTON = 1 + +export function TabNavItem(props: { + ref?: Ref + href: string + server: ServerConnection.Key + session: () => Session | undefined + fallbackTitle?: string + onRename: (title: string) => Promise + onClose: () => void + onNavigate: () => void + active?: boolean + forceTruncate?: boolean + suppressNavigation?: () => boolean + dragging?: boolean + pressed?: boolean + hidden?: boolean +}) { + const [editing, setEditing] = createSignal(false) + const [titleOverflowing, setTitleOverflowing] = createSignal(false) + let tabRoot!: HTMLDivElement + let titleEl!: HTMLSpanElement + let measureFrame: number | undefined + const rename = createMutation(() => ({ mutationFn: props.onRename })) + + const closeTab = (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + props.onClose() + } + const global = useGlobal() + const serverCtx = createMemo(() => { + const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server) + if (conn) return global.ensureServerCtx(conn) + }) + const project = createMemo(() => { + const session = props.session() + if (!session) return + return projectForSession(session, serverCtx()?.projects.list() ?? []) + }) + const title = createMemo(() => props.session()?.title ?? props.fallbackTitle) + + const projectName = createMemo(() => { + const session = props.session() + if (!session) return + return displayName(project() ?? { worktree: session.directory }) + }) + const previewPath = createMemo(() => { + const session = props.session() + if (!session) return + const home = serverCtx()?.sync.data.path.home + return home ? session.directory.replace(home, "~") : session.directory + }) + // Only label the server when multiple servers are connected. + const serverLabel = createMemo(() => { + if (global.servers.list().length <= 1) return + const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server) + return conn ? serverName(conn) : undefined + }) + + const [popoverOpen, setPopoverOpen] = createSignal(false) + const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session() + + const measureTitleOverflow = () => { + if (!titleEl || editing()) { + setTitleOverflowing(false) + return + } + setTitleOverflowing(titleEl.scrollWidth > titleEl.clientWidth) + } + + const scheduleTitleOverflow = () => { + if (measureFrame !== undefined) return + measureFrame = requestAnimationFrame(() => { + measureFrame = undefined + measureTitleOverflow() + }) + } + + createEffect(() => { + title() + props.forceTruncate + editing() + scheduleTitleOverflow() + }) + + createResizeObserver(() => tabRoot, scheduleTitleOverflow) + onCleanup(() => { + if (measureFrame !== undefined) cancelAnimationFrame(measureFrame) + }) + + const selectTitle = () => { + const range = document.createRange() + range.selectNodeContents(titleEl) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + } + + const closeRename = async (save: boolean) => { + if (rename.isPending || !editing()) return + + const original = props.session()?.title ?? "" + const next = (titleEl.textContent ?? "").trim() + + titleEl.scrollLeft = 0 + setEditing(false) + + if (!save || !next || next === original) { + return + } + + await rename.mutateAsync(next) + } + + createEffect(() => { + if (editing()) return + if (!titleEl) return + const value = title() + if (value === undefined) return + titleEl.textContent = value + }) + + const openRename = (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return + const session = props.session() + if (!session) return + titleEl.textContent = session.title + setEditing(true) + + requestAnimationFrame(() => { + titleEl.focus() + selectTitle() + }) + } + + createEffect(() => { + if (!editing()) return + + const cleanup = makeEventListener( + document, + "pointerdown", + (event) => { + const target = event.target + if (!(target instanceof Node)) return + if (tabRoot.contains(target)) return + void closeRename(true) + }, + { capture: true }, + ) + + onCleanup(cleanup) + }) + + const tab = ( +
{ + tabRoot = el + forwardTabRef(props.ref, el) + }} + data-titlebar-tab + data-slot="titlebar-tab-item" + data-title-overflow={titleOverflowing()} + data-editing={editing()} + class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] px-1.5 [container-type:inline-size]" + classList={{ invisible: props.hidden }} + data-active={props.active} + data-dragging={props.dragging} + data-state={props.active || props.pressed ? "pressed" : undefined} + onMouseDown={(event) => { + if (event.button !== MIDDLE_MOUSE_BUTTON) return + event.preventDefault() + event.stopPropagation() + }} + onAuxClick={(event) => { + if (event.button !== MIDDLE_MOUSE_BUTTON) return + closeTab(event) + }} + > + { + event.preventDefault() + event.stopPropagation() + }} + onMouseDown={(event) => { + // Navigate on mousedown to shave the press-release delay off tab switches. + if (event.button !== 0) return + if (editing()) return + if (props.suppressNavigation?.()) return + props.onNavigate() + }} + onClick={(event) => { + event.preventDefault() + // Mouse navigation already happened on mousedown; detail 0 means keyboard activation. + if (event.detail > 0) return + if (editing()) return + if (props.suppressNavigation?.()) return + props.onNavigate() + }} + class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]" + > + + + + { + titleEl = el + titleEl.textContent = title() ?? "" + }} + data-slot="tab-title" + data-titlebar-tab-title + class="min-w-0 flex-1 outline-none leading-4" + classList={{ + "overflow-hidden text-clip whitespace-nowrap": !editing(), + "select-text": editing(), + }} + contenteditable={editing() ? true : undefined} + onDblClick={openRename} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + void closeRename(true) + return + } + if (event.key !== "Escape") return + event.preventDefault() + titleEl.textContent = props.session()?.title ?? "" + void closeRename(false) + }} + onBlur={() => void closeRename(true)} + onPointerDown={(event) => { + if (!editing()) return + event.stopPropagation() + }} + onClick={(event) => { + if (!editing()) return + event.preventDefault() + }} + /> + + +
+ { + event.preventDefault() + event.stopPropagation() + }} + onClick={closeTab} + icon={} + /> +
+
+ ) + + return ( + { + if (value && previewBlocked()) return + setPopoverOpen(value) + }} + data={{ + projectName: projectName(), + title: props.session()?.title, + path: previewPath(), + serverName: serverLabel(), + }} + /> + ) +} + +export function DraftTabItem(props: { + ref?: Ref + href: string + title: string + active?: boolean + onNavigate: () => void + onClose: () => void + suppressNavigation?: () => boolean + dragging?: boolean + pressed?: boolean + hidden?: boolean +}) { + const language = useLanguage() + const closeTab = (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + props.onClose() + } + return ( +
forwardTabRef(props.ref, el)} + data-titlebar-tab + data-slot="titlebar-tab-item" + data-active={props.active} + data-dragging={props.dragging} + data-state={props.active || props.pressed ? "pressed" : undefined} + class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 [container-type:inline-size] whitespace-nowrap" + classList={{ invisible: props.hidden }} + onMouseDown={(event) => { + if (event.button !== MIDDLE_MOUSE_BUTTON) return + event.preventDefault() + event.stopPropagation() + }} + onAuxClick={(event) => { + if (event.button !== MIDDLE_MOUSE_BUTTON) return + closeTab(event) + }} + > + { + event.preventDefault() + event.stopPropagation() + }} + onMouseDown={(event) => { + // Navigate on mousedown to shave the press-release delay off tab switches. + if (event.button !== 0) return + if (props.suppressNavigation?.()) return + props.onNavigate() + }} + onClick={(event) => { + event.preventDefault() + // Mouse navigation already happened on mousedown; detail 0 means keyboard activation. + if (event.detail > 0) return + if (props.suppressNavigation?.()) return + props.onNavigate() + }} + class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]" + > + + + + + {props.title} + + +
+ { + event.preventDefault() + event.stopPropagation() + }} + onMouseDown={(event) => { + event.preventDefault() + event.stopPropagation() + }} + class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100" + onClick={closeTab} + icon={} + aria-label={language.t("common.closeTab")} + /> +
+
+ ) +} diff --git a/packages/app/src/components/titlebar-tab-order.test.ts b/packages/app/src/components/titlebar-tab-order.test.ts new file mode 100644 index 000000000000..8c524d401ccc --- /dev/null +++ b/packages/app/src/components/titlebar-tab-order.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test" +import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order" + +describe("adjacentTabKey", () => { + test("follows the visible left-to-right order", () => { + expect(adjacentTabKey(["c", "a", "b"], "c", 1)).toBe("a") + expect(adjacentTabKey(["c", "a", "b"], "a", -1)).toBe("c") + }) + + test("skips tabs omitted from the visible order", () => { + expect(adjacentTabKey(["a", "c"], "a", 1)).toBe("c") + expect(adjacentTabKey(["a", "c"], "c", 1)).toBe("a") + }) +}) + +test("merges reordered visible tabs around hidden tabs", () => { + expect(mergeVisibleTabOrder(["a", "hidden", "b", "c"], ["a", "b", "c"], ["c", "a", "b"])).toEqual([ + "c", + "hidden", + "a", + "b", + ]) +}) diff --git a/packages/app/src/components/titlebar-tab-order.ts b/packages/app/src/components/titlebar-tab-order.ts new file mode 100644 index 000000000000..a906b1fb329a --- /dev/null +++ b/packages/app/src/components/titlebar-tab-order.ts @@ -0,0 +1,12 @@ +export function adjacentTabKey(order: string[], current: string | undefined, offset: -1 | 1) { + if (!current || order.length === 0) return + const index = order.indexOf(current) + if (index === -1) return + return order[(index + offset + order.length) % order.length] +} + +export function mergeVisibleTabOrder(all: string[], current: string[], next: string[]) { + const visible = new Set(current) + const reordered = next.values() + return all.map((key) => (visible.has(key) ? (reordered.next().value ?? key) : key)) +} diff --git a/packages/app/src/components/titlebar-tab-popover.css b/packages/app/src/components/titlebar-tab-popover.css new file mode 100644 index 000000000000..e3b96dcaa4fd --- /dev/null +++ b/packages/app/src/components/titlebar-tab-popover.css @@ -0,0 +1,127 @@ +[data-component="session-tab-popover-trigger"] { + display: flex; + width: 100%; + min-width: 0; +} + +[data-component="session-tab-popover"] { + z-index: 50; + box-sizing: border-box; + width: 256px; + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px; + + background: var(--v2-background-bg-base); + border-radius: 6px; + box-shadow: var(--v2-elevation-floating); + + color: var(--v2-text-text-base); + font-variant-numeric: tabular-nums; + user-select: none; + pointer-events: none; + + transform-origin: var(--kb-hovercard-content-transform-origin); + /* Entrance: instant (trying no animation — may revert to 120ms ease-out). */ + animation: none; + + &:focus-visible, + &:focus-within { + outline: none; + } + + /* Exit: instant (trying no animation — may revert to 80ms ease-out). */ + &[data-closed] { + animation: none; + } + + /* Warm streak (rapid tab hopping): appear/disappear instantly so the enter + and exit animations don't repeat on every tab. */ + &[data-instant], + &[data-instant][data-closed] { + animation: none; + } + + [data-slot="header"] { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; + } + + [data-slot="project"] { + font-weight: 440; + font-size: 13px; + line-height: 1.5; + letter-spacing: -0.04px; + color: var(--v2-text-text-muted); + } + + [data-slot="title"] { + font-weight: 530; + font-size: 13px; + line-height: 1.5; + letter-spacing: -0.04px; + color: var(--v2-text-text-base); + overflow-wrap: anywhere; + } + + [data-slot="row"] { + display: flex; + width: 100%; + min-width: 0; + } + + [data-slot="detail"] { + flex: 1 0 0; + min-width: 0; + /* Wrap the full path onto the next line instead of clipping the end. */ + overflow-wrap: anywhere; + font-weight: 440; + font-size: 13px; + line-height: 1.5; + letter-spacing: -0.04px; + color: var(--v2-text-text-muted); + } + + [data-slot="server"] { + font-weight: 440; + font-size: 13px; + line-height: 1; + letter-spacing: -0.04px; + color: var(--v2-text-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +@keyframes sessionTabPopoverIn { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes sessionTabPopoverOut { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.96); + } +} + +@media (prefers-reduced-motion: reduce) { + [data-component="session-tab-popover"], + [data-component="session-tab-popover"][data-closed] { + animation: none; + } +} diff --git a/packages/app/src/components/titlebar-tab-popover.tsx b/packages/app/src/components/titlebar-tab-popover.tsx new file mode 100644 index 000000000000..0689302ca6a6 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-popover.tsx @@ -0,0 +1,92 @@ +import { HoverCard as Kobalte } from "@kobalte/core/hover-card" +import { createSignal, Show, type JSXElement } from "solid-js" +import "./titlebar-tab-popover.css" + +// Initial hover delay before the preview appears, per design. +const OPEN_DELAY = 2_000 +// Mouse-out delay: begin closing immediately (a brief exit animation plays). +const CLOSE_DELAY = 0 +// After a preview closes, hovering a neighbouring tab within this window skips +// the open delay — mirrors the tooltip's skipDelayDuration so moving across +// tabs doesn't re-wait the full delay each time. +const SKIP_WINDOW = 500 +let lastClosedAt = 0 + +export interface TabPreviewData { + projectName?: string + title?: string + path?: string + serverName?: string +} + +export function TabPreviewPopover(props: { + trigger: JSXElement + open: boolean + onOpenChange: (open: boolean) => void + data: TabPreviewData +}) { + let triggerEl: HTMLDivElement | undefined + // When opened during a rapid tab-hopping streak, this preview appears and + // disappears instantly (no repeated enter/exit animation) — only the first, + // "cold" preview animates. Mirrors how browsers reuse one tab tooltip. + const [instant, setInstant] = createSignal(false) + + const warm = () => Date.now() - lastClosedAt < SKIP_WINDOW + // Kobalte reads openDelay lazily when the pointer enters the trigger, so this + // resolves the skip window per-hover. + const resolveOpenDelay = () => (warm() ? 0 : OPEN_DELAY) + const handleOpenChange = (open: boolean) => { + if (open) setInstant(warm()) + else lastClosedAt = Date.now() + props.onOpenChange(open) + } + + return ( + + + {props.trigger} + + + { + // Portalled content lives outside the themed subtree, so mirror the + // active theme like the v2 tooltip does. + const theme = triggerEl?.closest("[data-theme]")?.getAttribute("data-theme") + if (theme) el.setAttribute("data-theme", theme) + }} + data-component="session-tab-popover" + data-instant={instant() || undefined} + > +
+ + {props.data.projectName} + + + {props.data.title} + +
+ + +
+ {props.data.path} +
+
+ + +
{props.data.serverName}
+
+
+
+
+ ) +} diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx new file mode 100644 index 000000000000..0a4b4da12dfe --- /dev/null +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -0,0 +1,417 @@ +import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { createResizeObserver } from "@solid-primitives/resize-observer" +import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" +import { isSortable, useSortable } from "@dnd-kit/solid/sortable" +import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom" +import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers" +import { RestrictToElement } from "@dnd-kit/dom/modifiers" +import { arrayMove } from "@dnd-kit/helpers" +import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs" +import { ServerConnection } from "@/context/server" +import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav" +import { useGlobal, type ServerCtx } from "@/context/global" +import { useLanguage } from "@/context/language" +import { useCommand } from "@/context/command" +import { useTabs } from "@/context/tabs" +import { createTabPromptState } from "@/context/prompt" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { showToast } from "@/utils/toast" +import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture" +import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order" +import type { Session } from "@opencode-ai/sdk/v2" + +function SessionTabSlot(props: { + tab: SessionTab + id: string + index: () => number + active: () => boolean + forceTruncate: boolean + session: () => Session | undefined + fallbackTitle?: string + onRename: (title: string) => Promise + onNavigate: (element: HTMLDivElement) => void + onClose: () => void +}) { + const sortable = useSortable({ + get id() { + return props.id + }, + get index() { + return props.index() + }, + }) + let ref!: HTMLDivElement + + return ( +
+ { + ref = el + }} + href={tabHref(props.tab)} + server={props.tab.server} + session={props.session} + fallbackTitle={props.fallbackTitle} + onRename={props.onRename} + onNavigate={() => props.onNavigate(ref)} + onClose={props.onClose} + active={props.active()} + forceTruncate={props.forceTruncate} + dragging={sortable.isDragSource()} + /> +
+ ) +} + +function SessionTabEntry(props: { + tab: SessionTab + id: string + index: () => number + active: () => boolean + forceTruncate: boolean + serverCtx: () => ServerCtx | undefined + onVisibleChange: (visible: boolean) => void + onNavigate: (element: HTMLDivElement) => void + onClose: () => void +}) { + const tabs = useTabs() + const language = useLanguage() + const sdk = createMemo(() => props.serverCtx()?.sdk ?? null) + const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId)) + const persisted = createMemo(() => tabs.info[props.id]) + const [loadedSession] = createResource( + () => { + const ctx = props.serverCtx() + return ctx ? { id: props.tab.sessionId, ctx } : null + }, + ({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined), + ) + const session = createMemo(() => cachedSession() ?? loadedSession()) + const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session()) + const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title) + let prefetched = false + + const rename = async (title: string) => { + const value = session() + const ctx = props.serverCtx() + if (!value || !ctx) return + + ctx.sync.session.remember({ ...value, title }) + try { + await ctx.sdk.api.session.rename({ sessionID: value.id, title }) + } catch (err) { + const current = session() + const currentCtx = props.serverCtx() + if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title }) + showToast({ + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : undefined, + }) + } + } + + createEffect(() => props.onVisibleChange(visible())) + + createEffect(() => { + const ctx = props.serverCtx() + const value = session() + if (!ctx || !value || prefetched) return + prefetched = true + createRoot((dispose) => { + try { + void ctx.sync + .ensureDirSyncContext(value.directory) + .session.sync(value.id) + .catch(() => {}) + .finally(dispose) + } catch { + dispose() + } + }) + }) + + createEffect(() => { + const value = session() + if (!value) return + tabs.rememberSessionInfo(props.tab, value) + const current = sdk() + if (!current) return + createTabPromptState(tabs, props.tab, current.scope, { + dir: base64Encode(value.directory), + id: value.id, + }) + }) + + return ( + + + + ) +} + +function DraftTabSlot(props: { + tab: Extract + id: string + index: () => number + active: () => boolean + title: string + onNavigate: (element: HTMLDivElement) => void + onClose: () => void +}) { + const sortable = useSortable({ + get id() { + return props.id + }, + get index() { + return props.index() + }, + }) + let ref!: HTMLDivElement + + return ( +
+ { + ref = el + }} + href={tabHref(props.tab)} + title={props.title} + onNavigate={() => props.onNavigate(ref)} + onClose={props.onClose} + active={props.active()} + dragging={sortable.isDragSource()} + /> +
+ ) +} + +export function TitlebarTabStrip(props: { + tabs: Tab[] + currentTab: () => Tab | undefined + forceTruncate: boolean + onNavigate: (tab: Tab, el?: HTMLDivElement) => void + onClose: (tab: Tab) => void + onReorder: (keys: string[]) => void + onOverflowChange: (overflowing: boolean) => void +}) { + const global = useGlobal() + const language = useLanguage() + const command = useCommand() + let scrollRef!: HTMLDivElement + let listRef!: HTMLDivElement + let resizeFrame: number | undefined + const [visibility, setVisibility] = createStore>({}) + const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)])) + const visibleTabIds = () => visibleTabs().map(tabKey) + + command.register("titlebar-tab-cycle", () => [ + { + id: `tab.prev`, + category: "tab", + title: "", + keybind: `mod+option+ArrowLeft,ctrl+shift+tab`, + hidden: true, + onSelect: () => selectAdjacentTab(-1), + }, + { + id: `tab.next`, + category: "tab", + title: "", + keybind: `mod+option+ArrowRight,ctrl+tab`, + hidden: true, + onSelect: () => selectAdjacentTab(1), + }, + ]) + + function selectAdjacentTab(offset: -1 | 1) { + const current = props.currentTab() + const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset) + const next = props.tabs.find((tab) => tabKey(tab) === key) + if (next) props.onNavigate(next) + } + + function refreshOverflow() { + if (!scrollRef) return + props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth) + } + + createResizeObserver( + () => [scrollRef, listRef], + () => { + if (resizeFrame !== undefined) return + resizeFrame = requestAnimationFrame(() => { + resizeFrame = undefined + refreshOverflow() + }) + }, + ) + + onMount(() => { + refreshOverflow() + }) + + onCleanup(() => { + if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame) + }) + + createEffect(() => { + props.tabs.length + visibleTabIds() + refreshOverflow() + }) + + return ( +
+
+ + !canStartTabDrag(event.pointerType) || + isTabCloseTarget(event.target) || + (event.target instanceof Element && !!event.target.closest('[contenteditable="true"]')), + }), + ]} + modifiers={[RestrictToHorizontalAxis, RestrictToElement.configure({ element: () => listRef })]} + plugins={(defaults) => [ + ...defaults.filter((plugin) => plugin !== Accessibility), + AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }), + Feedback.configure({ dropAnimation: null }), + ]} + onDragStart={(event) => { + const source = event.operation.source + if (!source) return + const tab = props.tabs.find((item) => tabKey(item) === source.id.toString()) + if (!tab) return + const tabEl = source.element?.querySelector("[data-titlebar-tab]") + props.onNavigate(tab, tabEl ?? undefined) + }} + onDragEnd={(event) => { + const current = visibleTabIds() + const source = event.operation.source + if (event.canceled || !isSortable(source)) return + + const { initialIndex, index } = source + if (initialIndex !== index) { + props.onReorder( + mergeVisibleTabOrder( + props.tabs.map(tabKey), + current, + arrayMove(current, source.initialIndex, source.index), + ), + ) + } + }} + > +
+ + {(tab) => { + const id = tabKey(tab) + let ref!: HTMLDivElement + const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id) + useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref)) + const serverCtx = createMemo(() => { + if (tab.type !== "session") return + const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server) + if (conn) return global.ensureServerCtx(conn) + }) + + if (tab.type === "session") { + return ( + props.currentTab() === tab} + forceTruncate={props.forceTruncate} + serverCtx={serverCtx} + onVisibleChange={(visible) => setVisibility(id, visible)} + onNavigate={(element) => { + ref = element + props.onNavigate(tab, element) + }} + onClose={() => props.onClose(tab)} + /> + ) + } + + return ( + props.currentTab() === tab} + title={language.t("command.session.new")} + onNavigate={(element) => { + ref = element + props.onNavigate(tab, element) + }} + onClose={() => props.onClose(tab)} + /> + ) + }} + +
+
+
+