diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 52eec90991..9501a1be65 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 8930ba693c..0000000000 --- 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 f892eb95db..ee2e26f452 100644 --- a/.github/TEAM_MEMBERS +++ b/.github/TEAM_MEMBERS @@ -1,4 +1,5 @@ adamdotdevin +arvsrn Brendonovich fwang Hona @@ -7,11 +8,14 @@ jayair jlongster kitlangton kommander +ludvigrask MrMushrooooom 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 5b44517ec5..9d29724d86 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/compliance-close.yml b/.github/workflows/compliance-close.yml index 14e68701e5..a83824e5cf 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/duplicate-issues.yml b/.github/workflows/duplicate-issues.yml index 4648a2d0c3..3972247daf 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 29b5aa3047..15f865b742 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,13 @@ 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_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ vars.SENTRY_ORG }} diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index 1e652104d6..be2e099d0e 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 ca3740c90a..cd4f0eb7c2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -79,10 +79,15 @@ jobs: - name: Run unit tests timeout-minutes: 45 - run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task + run: GITHUB_ACTIONS=false bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task 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 diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml index 27852a12ce..0350e43877 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/command/translate.md b/.opencode/command/translate.md index 8d493f4a81..de18ae2ee8 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 ae68a477dc..b0f7d59447 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -2,9 +2,7 @@ "$schema": "https://opencode.ai/config.json", "provider": {}, "permission": {}, - // TODO: flip back to `references` once a release containing the v1 `reference` migration ships. - // The release pipeline runs the latest published opencode against this file, which only knows `reference`. - "reference": { + "references": { "effect": { "repository": "github.com/Effect-TS/effect-smol", "description": "Use for Effect v4 and effect-smol implementation details", diff --git a/.opencode/tool/github-triage.ts b/.opencode/tool/github-triage.ts index f25ca48b38..e861e1e467 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", "nexxeln", "kitlangton"], inference: ["fwang", "MrMushrooooom", "starptech"], windows: ["Hona"], } as const diff --git a/.prettierignore b/.prettierignore index a2a2776596..7c72d70225 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 c839668586..cd2327e888 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/AMICODE-PATCHES.md b/AMICODE-PATCHES.md index c098e2dcf0..7de875daa3 100644 --- a/AMICODE-PATCHES.md +++ b/AMICODE-PATCHES.md @@ -1,8 +1,44 @@ # AMICODE patch-stack log -Local fork of sst/opencode @ v1.17.3 on branch `local/amicode`. Remote: `upstream` only +Local fork of sst/opencode @ v1.18.10 (merge `042634f37` + follow-up `fb6587f3a`, 2026-08-01) on branch `amicode/merge-upstream-dev`, pending fast-forward of `local/amicode` (was v1.17.3). Remote: `upstream` only (fetch = github.com/sst/opencode, push URL disabled to `no_push_disabled`). Never push. +## Upstream sync 2026-08-01 (1.17.3 โ†’ 1.18.10, merge-base 2026-06-10) + +Merged sst/opencode `dev` @ `19231fce4b` (1,229 upstream commits) into the fork's 472. 78 conflicts +resolved by policy: versions/lockfile mechanical; ~40 files hand-merged. Notables: + +- **Adopted upstream's**: session-ui package split (fork's `message-part-groups`/`message-part-skill` + moved there with their consumers), review/diffs side panel (fork's vault tab grafted in; + `reviewOpen = tabsOpen` โ€” same persisted key), controller-driven composer/settings/home, + AppNodeBuilder test harness (fork's `promptAgnosticMatcher` kept inside it), bounded SSE + + heartbeat, **native per-event-location contract** (supersedes the fork's connection-pinned + streams; the app reducer filters per-directory client-side). +- **Kept fork's**: branding/fonts/accents, KaTeX macros (now threaded into upstream's hand-rolled + `renderKatexToken`), AmicoSpinner sites, entity-rail card dispatch (new + `@opencode-ai/ui/amicode-*` shims; `packages/ui` now depends on `@opencode-ai/session-ui` for + ask-card's data context โ€” module-level acyclic), titlebar inline tab strip (server-wide + `sync.session.get` replaces the deleted `dirBase64`), prompt-agnostic cassette matcher. +- **Dropped**: old `message-timeline.tsx` mounts (entity rail, AmicoSpinner, ask bridge) โ€” upstream + deleted the file; ports into `pages/session/timeline/` are FOLLOW-UPS. `debug-bar.tsx` stays + deleted. `showSessionProgressBar` row dropped (its consumer died with the timeline). Fork's + markdown polish (~75 lines, heading hierarchy + inline-code chip) needs porting into + session-ui's markdown. +- **prompt.ts silent-turn guard REFINED**: fires only on reasoning-bearing turns with no error. + Upstream treats mid-stream provider explosions as finish `unknown` (no error, no reasoning) and + its run-process tests lock "end the run" for that shape โ€” the unrefined guard re-invoked them. +- **i18n parity**: upstream's strict parity test demands every en.ts key in every locale; the 72 + amicode-era English keys were filled into all 17 app locales as ENGLISH fallbacks (verbatim, + placeholders preserved). Non-English translations still deliberately unreviewed. +- **Test-suite gotcha (this machine)**: the Amicode session exports `OPENCODE_CONFIG_CONTENT` + (skills.paths + permissions) โ€” it leaks into any `bun test` spawned from an Amicode shell and + breaks skill/permission fixtures. Run suites with + `env -u OPENCODE_CONFIG_CONTENT -u OPENCODE_SERVER_PASSWORD bun test`. +- **Known reds, all pre-existing or upstream-flaky**: pasqal connections (8) + amicode widgets (15) + fail identically on `origin/local/amicode` (WIP features); `httpapi-v2-pty` "serves + location-wrapped PTY routes" times out ~1-in-3 on clean upstream on this machine. +- Build recipe below is unchanged (channel gate gotcha 2 still applies: `OPENCODE_CHANNEL=dev`). + ## Build recipe (v1.17.3) Toolchain: bun 1.3.14 (`~/.bun/bin/bun` โ€” must ALSO be on PATH, see gotcha 1). Node/pnpm not used by this repo (pure Bun workspace). @@ -353,3 +389,42 @@ Rebuilt with the exact T3 recipe (`OPENCODE_VERSION=1.17.3 bun run script/build. - STILL UNVERIFIED (needs live API creds): whether the OLD legacy `thinking:{type:"enabled",budgetTokens}` form actually 400s against Opus 5 or merely degrades โ€” i.e. whether this was "hobbled" or "unusable". Also unexercised by CI: the Grok/GLM effort variants and the opus-4-5 `budgetTokens`+`effort` combination. - FUTURE SYNC COST: this is a knowing trade โ€” porting file end-states now makes a future clean upstream merge harder on these 4 files. Accepted. NOTE the bug class recurs every model generation (version-regex parsing of model IDs will break again at Opus 6), which argues for a standing narrow sync lane on `transform.ts` rather than one-off unfreezes. - SEPARATE RISK SPOTTED (not fixed here): the release build does a bare `fetch(models.dev/api.json)` with no fallback, so a models.dev outage hard-fails the build. Consider pinning `MODELS_DEV_API_JSON` for the hackathon build. + +25. (clickable chat links โ€” file:// opens in the editor, 2026-08-01) โ€” amicode: markdown links in chat were dead wholesale (Aaron, live: vault-note links authored per the amico-vault skill's new `file://` convention โ€” "clicking does nothing"). Diagnosis, three layers, all confirmed in source: (1) the marked link renderer emits `` intact โ€” no sanitization (packages/ui/src/context/marked.tsx:572); (2) session-ui's markdown component had NO anchor click handler at all โ€” its only listener was the code-copy button, so anchors fell to default `target="_blank"` navigation, which the sandboxed cross-origin chat iframe blocks โ€” every scheme dead, not just file://; (3) even past (2), two downstream filters would have dropped file://: the app platform `openExternal` (http/https/mailto only, packages/app/src/entry.tsx:87) and the extension bridge (https-only regex, amicode packages/extension/src/chat_bridge.ts). + - FORK: packages/session-ui/src/components/markdown.tsx โ€” NEW `setupExternalLinks(root)`: delegated click handler on `a.external-link`, framed contexts only (`window.parent !== window`; a plain browser tab keeps native behavior). `preventDefault` + postMessage to parent: https/mailto โ†’ `{source:"amicode", kind:"open-external", url}`, file:// โ†’ NEW bridge kind `{kind:"open-file", url}`; any other scheme untouched (native fallback, dead-in-iframe as before). Wired next to `setupCodeCopy` with the same mount-once-in-effect + onCleanup pattern. + - EXTENSION (amicode repo): chat_bridge.ts โ€” new `open-file` branch: kind + string + `^file://` gate in one condition (non-file โ†’ `false`, not consumed โ€” open-external idiom); then `decodeURIComponent(new URL(url).pathname)`, must be `path.isAbsolute` + โ‰ค4096 + `fs.existsSync` โ†’ `vscode.commands.executeCommand("vscode.open", vscode.Uri.file(fsPath))`; unparseable/unreachable โ†’ consumed-silent (save-file idiom). Posix path semantics (fleet is mac/linux). Relay allowlists (chat_panel.ts lane-1, deck/shell.ts) gain `"open-file"`. + - Skill companion (already landed, becomes live with this build): amico-vault SKILL.md "Reporting writes in chat" โ€” vault writes are reported as absolute `file://` markdown links, one per file. + - Tests: extension `chat_bridge.test.ts` +2 (opens an existing file incl. a %-decoded space path; rejects non-file schemes as not-consumed, missing files/non-absolute as consumed-silent) โ†’ file 9/9; full extension suite 843/846 โ€” the 3 failures are PRE-EXISTING and environmental (server_auth.test.ts spawn-env resolves the machine's provisioned `~/.amico/amicode/venvs/pasqal-connector` interpreter; logic committed at HEAD in extension.ts/pasqal_python.ts, untouched by this diff). session-ui `bun test src/components` โ†’ 69 pass / 0 fail. Extension `tsc --noEmit` clean. + - Build sha256: `df1dbe0de082a65c181e6aba7546acd4694842e76cc035c69b95352d08c5dac1` (packages/opencode/dist/opencode-darwin-arm64/bin/opencode + convenience copy dist/opencode-local; vendored into amicode packages/extension/vendor/opencode/darwin-arm64/opencode AND swapped into the installed extension `~/.vscode/extensions/harmoniqs.amicode-0.1.2/vendor/...` โ€” plain cp, ETXTBSY not hit). Verify: binary contains `open-file` ร—8; channel-gate sentinel `newLayoutDesigns` `!0` present; smoke `--version` โ†’ 1.17.3. Extension dist (`extension.js` open-file ร—2, `deck_shell.js` ร—1) copied into the installed extension's dist/. + - **Interactive click โ†’ `vscode.open` in the live webview NOT yet human-verified** โ€” needs a window reload (extension host + spawned server both restart from the new artifacts); same deferred-acceptance caveat as #12/#13/#16. + - Divergence note: the vendored binary is now AHEAD of amicode `opencode.lock.json`'s pinned fork ref until the fork commits (`session-ui markdown.tsx`) and the lock bumps โ€” normal amicode.N tag flow, not done here. +24. (harmonic wave indicator โ€” de-shimmer the tool-status title, 2026-07-28) โ€” amicode: the chat had two "working" indicators that read as identical โ€” the fork's thinking line and the STOCK tool-group header (`packages/ui/src/components/tool-status-title.tsx`, e.g. "Working in shell" โ†’ "Worked in shell") โ€” because both were shimmering text with no glyph. Earlier steps of this run gave the thinking line a standing-wave glyph (`AmicoWave`, `packages/ui/src/amicode/amico-wave.tsx`) and deleted the `amc-text-shimmer` CSS; this patch removes the shimmer from the last surface still asking for it. + - `tool-status-title.tsx`: all five `` render positions (swap-mode active/done, suffix-mode prefix/active/done) replaced with plain `{text}`. `TextShimmer` import removed. The now-orphaned `prefixLen` memo (it existed only to feed the shimmer's `offset` phase-alignment prop) removed too โ€” confirmed via `oxlint` before/after (498โ†’499โ†’498 warnings) that leaving it in place tripped `no-unused-vars`, so dropping it was the correct call rather than "keep everything." + - Deliberately PRESERVED: the `common()` prefix-splitting logic, the width-morph animation (`animate()`/`finish()`, the `requestAnimationFrame`, the stored `width`, the `data-ready` flag), the `data-component`/`data-active`/`data-mode` attributes, the `aria-label`, and the activeโ†’done text swap itself. None of that is the shimmer โ€” it's the genuinely good word-morph behavior. + - `text-shimmer.tsx` untouched โ€” it has many other live callers (`basic-tool.tsx`, `message-part.tsx` ร—7, `session-turn.tsx`, `v2/components/basic-tool-v2.tsx` via `text-shimmer-v2.tsx`, plus stories), confirmed via `rg -n 'TextShimmer' packages/`. Not a candidate for deletion. + - The dropped `offset` prop existed only so the shimmer's gradient phase stayed continuous across the prefix/tail split; with no shimmer there's nothing to phase-align, so it has no replacement. Verified live (Storybook, `UI/AnimatedCountList` stories) that the prefix and tail still read as one unbroken word with no seam: in the swap-mode `Playground` story, driving an activeโ†’done transition showed the mid-animation frame rendering both the active and done spans simultaneously under the animating width (`data-ready="true"`, container `style="width: 0px"` mid-transition, then settling) โ€” the word-morph is intact. The suffix/prefix-tail mode (`data-mode="suffix"`) is currently unreachable from any real call site โ€” both app usages (`message-part.tsx` context-tool-group and shell-group titles) and the only story pass `split={false}` โ€” so it was verified by transiently flipping one story's `split` prop off (`Done` export, "Exploring"/"Explored"), confirming `data-mode="suffix"` renders `"Explor"` + `"ed"` as an unbroken "Explored" with no visible gap, then reverting that story edit before commit (`git status` shows only the two files below). + - Tests: ui `bun test src` โ†’ 402 pass / 0 fail (unchanged). typecheck (tsgo) clean in `packages/ui`. `oxlint packages/ui/src` โ†’ 498 warnings / 0 errors both before and after (no new warnings once `prefixLen` was dropped). + +## Feature-branch recovery 2026-08-01 (after the upstream sync) + +The upstream sync covered only `local/amicode`; live work sat on unmerged feature branches. +Merged into `amicode/merge-upstream-dev` on top of the sync: + +- `ann/thinking-words-updated` (Quantizing/Obsidianing), `feat/amicode-system-card-model`, + `fix/amicode-receipt-currency` โ€” clean merges. +- `pr/amico-working-indicator` โ€” the harmonic wave indicator (AmicoWave in the thinking line, + H-mark static; tool-status-title de-shimmered) + collapsed receipt runs + AmicoSkillChip. + Conflicts: message-part countร—useV2Actions threading unioned; shims amicode-receipt(-runs). +- `kate/context-tree-vault-zoom` โ€” framed external links route over the open-external bridge + (kept upstream's openExternal; branch's global link interceptor dropped โ€” patch #25 covers it). +- `feat/warrant-receipts-captured` (contains approval-surfaces) โ€” capability warrants: + rail warrant chip, approval card (deliberately NOT onAsk), /amicode/warrants + /amicode/approve. +- `feat/harmonic-wave-indicator` โ€” recorded-Hamiltonian rendering, system-card physics rows, + KaTeX dedupe (spine/aside-lane cancel against their own reverts). + +DELIBERATELY NOT MERGED: `amico/issue-56-living-chat` (34 commits โ€” glass sweep, Latent +Constellation, bottom-dock composer, thought camera). It redesigns exactly the surfaces +upstream replaced (old message-timeline, session-new-design-view, prompt-input, markdown โ€” +all deleted/moved upstream), 23 conflicted files. Merging would hybridize two design +directions into neither. It stays on its branch; reviving the glass recipe is a deliberate +port project onto the NEW ui, not a merge. diff --git a/CONTEXT.md b/CONTEXT.md index 6c94d90da6..b2698dccf9 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,39 @@ _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 + +**Vault Drawer**: +The standalone, globally mounted vault file-browser surface (left drawer) โ€” the vault's only host, driven solely by the module-scope `vaultPanel` store and rendered on every route (home, new-session, session). +_Avoid_: vault tab (retired), vault panel (ambiguous with the **Status Popover**'s vault-mount manager) + +**Work Column**: +The per-session right-hand column hosting the files-changed navigator, the context tree, and file tabs, owned solely by the session's `reviewPanel` store. Diffs render as full-width file tabs within the column, never as a split second pane. +_Avoid_: review panel, side panel, tabs column (legacy names from the pre-merge UI) + +**Status Popover**: +The titlebar's global health-and-connections surface: server health, MCP/LSP status, vault-mount and connection-credential management. Complements the **Vault Drawer** (browser vs manager); it does not browse files. +_Avoid_: status button (it is a popover), drawers menu + ## Relationships - A **System Context** is an opaque carrier composed from zero or more **Context Sources**. @@ -64,6 +109,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 +122,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 +210,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/README.zh.md b/README.zh.md index 93f81e83cd..352e13ccaa 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 59d9709bd3..f09358b133 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 0000000000..9d38bb2591 --- /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 0000000000..cac009ae81 --- /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 0000000000..3e182b4c11 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 0000000000..eb456d0345 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 0000000000..678376dbed 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 0000000000..8ca6b678ae 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 0000000000..b94a899257 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 0000000000..64d382da26 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 0000000000..fb08574eb2 --- /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 0000000000..662ca244cd 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 0000000000..d9ed232a89 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 0000000000..d9fab2a631 --- /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 0000000000..5d7aaef9bf --- /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 0000000000..3265151ac5 --- /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 0000000000..aa3f9de9be --- /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 0000000000..44cf09a1e7 --- /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 0000000000..9d649de4bb --- /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 0000000000..53abb39ee5 --- /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 0000000000..41466a7e6c --- /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 0000000000..64441936d7 --- /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 1960aa0ac0..07d75d1a5e 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.17.3", + "version": "1.18.10", "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.17.3", + "version": "1.18.10", "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.10", + "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.17.3", + "version": "1.18.10", "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.17.3", + "version": "1.18.10", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -173,7 +221,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.17.3", + "version": "1.18.10", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -195,7 +243,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.17.3", + "version": "1.18.10", "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.17.3", + "version": "1.18.10", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -239,7 +287,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.17.3", + "version": "1.18.10", "bin": { "opencode": "./bin/opencode", }, @@ -247,7 +295,7 @@ "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.49", + "@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,15 +303,15 @@ "@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:", @@ -275,6 +323,8 @@ "@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", @@ -285,10 +335,11 @@ "ai-gateway-provider": "3.1.2", "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,7 +381,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.17.3", + "version": "1.18.10", "dependencies": { "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -339,7 +390,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", @@ -384,7 +434,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.17.3", + "version": "1.18.10", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -398,7 +448,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.17.3", + "version": "1.18.10", "dependencies": { "effect": "catalog:", }, @@ -410,10 +460,11 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.17.3", + "version": "1.18.10", "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 +492,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.17.3", + "version": "1.18.10", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -457,10 +508,10 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.17.3", + "version": "1.18.10", "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 +522,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.17.3", + "version": "1.18.10", "dependencies": { + "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", @@ -494,7 +558,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.17.3", + "version": "1.18.10", "bin": { "opencode": "./bin/opencode", }, @@ -505,22 +569,22 @@ "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.49", - "@ai-sdk/cerebras": "2.0.41", + "@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:", @@ -532,8 +596,11 @@ "@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:*", @@ -565,7 +632,7 @@ "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", @@ -577,7 +644,7 @@ "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", @@ -589,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", @@ -623,8 +690,9 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.17.3", + "version": "1.18.10", "dependencies": { + "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", "zod": "catalog:", @@ -639,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", @@ -649,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": { @@ -659,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.17.3", + "version": "1.18.10", "dependencies": { "cross-spawn": "catalog:", }, @@ -676,9 +781,10 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.17.3", + "version": "1.18.10", "dependencies": { "@opencode-ai/core": "workspace:*", + "@opencode-ai/protocol": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:", }, @@ -688,9 +794,53 @@ "@typescript/native-preview": "catalog:", }, }, + "packages/session-ui": { + "name": "@opencode-ai/session-ui", + "version": "1.18.10", + "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:", + "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", + "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/katex": "0.16.7", + "@types/luxon": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + }, + }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.17.3", + "version": "1.18.10", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -703,9 +853,10 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.17.3", + "version": "1.18.10", "dependencies": { "@ibm/plex": "6.4.1", + "@kobalte/core": "catalog:", "@opencode-ai/stats-core": "workspace:*", "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -736,7 +887,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.17.3", + "version": "1.18.10", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -755,7 +906,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.17.3", + "version": "1.18.10", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -774,6 +925,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", @@ -786,6 +938,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", @@ -795,7 +948,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.17.3", + "version": "1.18.10", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -804,7 +957,6 @@ "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", - "@solid-primitives/scheduled": "1.5.2", "clipboardy": "4.0.0", "diff": "catalog:", "effect": "catalog:", @@ -823,19 +975,17 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.17.3", + "version": "1.18.10", "dependencies": { "@kobalte/core": "catalog:", - "@opencode-ai/core": "workspace:*", - "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/session-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:", - "@solidjs/router": "catalog:", "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", "dompurify": "3.3.1", @@ -852,28 +1002,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.17.3", + "version": "1.18.10", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -915,15 +1071,20 @@ "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/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", }, "overrides": { "@opentui/core": "catalog:", @@ -934,9 +1095,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", @@ -944,18 +1106,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", @@ -970,25 +1134,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": "17.0.6", "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", @@ -1018,7 +1182,7 @@ "@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=="], @@ -1028,7 +1192,7 @@ "@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=="], @@ -1042,7 +1206,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=="], @@ -1058,7 +1222,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=="], @@ -1284,6 +1448,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=="], @@ -1312,6 +1478,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=="], @@ -1320,17 +1490,31 @@ "@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=="], @@ -1794,6 +1978,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"], @@ -1820,16 +2008,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"], @@ -1874,27 +2072,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=="], @@ -2148,9 +2346,13 @@ "@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.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=="], + "@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@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="], - "@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="], + "@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=="], @@ -2166,6 +2368,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=="], @@ -2354,13 +2558,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=="], @@ -2500,6 +2708,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=="], @@ -2604,6 +2814,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=="], @@ -2620,6 +2834,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=="], @@ -2808,6 +3034,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=="], @@ -2854,7 +3082,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=="], @@ -2928,6 +3156,8 @@ "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=="], @@ -3056,7 +3286,7 @@ "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=="], @@ -3372,7 +3602,7 @@ "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=="], @@ -3654,7 +3884,7 @@ "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=="], @@ -3934,6 +4164,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=="], @@ -4102,13 +4338,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": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], "marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="], @@ -4416,13 +4654,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=="], @@ -4576,6 +4814,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=="], @@ -4858,7 +5100,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=="], @@ -4900,18 +5142,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=="], @@ -5134,22 +5384,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=="], @@ -5258,7 +5498,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=="], @@ -5268,8 +5508,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=="], @@ -5404,8 +5642,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=="], @@ -5454,7 +5690,11 @@ "@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/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/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": ["@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=="], @@ -5466,9 +5706,9 @@ "@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=="], @@ -5494,7 +5734,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=="], @@ -5508,7 +5750,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=="], @@ -5518,8 +5764,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=="], @@ -5728,8 +5978,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=="], @@ -5808,13 +6062,15 @@ "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], - "@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/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.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=="], @@ -5822,7 +6078,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=="], @@ -5830,6 +6090,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=="], @@ -5858,13 +6120,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=="], @@ -5890,6 +6156,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=="], @@ -5918,6 +6188,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=="], @@ -5934,8 +6208,16 @@ "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/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-gateway-provider/@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-gateway-provider/@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-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/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-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=="], "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=="], @@ -5960,14 +6242,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=="], @@ -6050,6 +6338,8 @@ "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=="], @@ -6096,6 +6386,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=="], @@ -6112,6 +6404,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=="], @@ -6144,7 +6438,9 @@ "nypm/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], - "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/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.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=="], @@ -6152,6 +6448,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=="], @@ -6208,14 +6508,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=="], @@ -6234,6 +6536,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=="], @@ -6252,20 +6556,24 @@ "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=="], "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.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=="], + "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=="], "vitest/@vitest/expect": ["@vitest/expect@4.1.7", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w=="], @@ -6346,12 +6654,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=="], @@ -6626,6 +6948,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=="], @@ -6648,6 +6974,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=="], @@ -6666,10 +6994,18 @@ "@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=="], @@ -6686,6 +7022,18 @@ "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/azure/@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=="], + + "ai-gateway-provider/@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-gateway-provider/@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-gateway-provider/@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-gateway-provider/@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-gateway-provider/@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=="], + "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -6712,6 +7060,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=="], @@ -6816,6 +7176,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=="], @@ -6848,6 +7220,10 @@ "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=="], @@ -7060,6 +7436,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=="], @@ -7076,6 +7454,12 @@ "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/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "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=="], @@ -7144,6 +7528,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=="], diff --git a/bunfig.toml b/bunfig.toml index b914f4b67b..c506ff57c4 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", "@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", "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", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"] +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/docs/adr/0001-one-home-per-chrome-surface.md b/docs/adr/0001-one-home-per-chrome-surface.md new file mode 100644 index 0000000000..6c3b23ead3 --- /dev/null +++ b/docs/adr/0001-one-home-per-chrome-surface.md @@ -0,0 +1,17 @@ +# One home per chrome surface: the vault is a global drawer, the right column is the Work Column + +Status: accepted (2026-08-01) + +Tracking: harmoniqs/opencode#105 ยท Glossary: `CONTEXT.md` (Vault Drawer, Work Column, Status Popover) + +Each of the titlebar's three right-side buttons drives exactly one surface backed by exactly one store, with no state-mirroring effects between them. The vault is the standalone left **Vault Drawer** on every route โ€” its session side-panel tab is retired. The right column is the **Work Column**: files-changed navigator (default tab), context tree, and file tabs, owned solely by the per-session persisted `reviewPanel` store. Diffs open as full-column-width file tabs; the review panel never renders its own sidebar+content split inside the column. The **Status Popover** keeps its health + connections contents and is re-anchored honestly (the `shift={-168}` magic offset is deleted in favor of `bottom-end` placement with standard collision handling). + +**Why:** the pre-merge build hosted the vault twice โ€” the standalone drawer outside sessions and a side-panel tab inside โ€” mirrored through two stores of different scope (module-scope `vaultPanel` signal vs per-session persisted `reviewPanel`), kept consistent by two `createEffect` mirrors plus a close-sync effect, and failing at it. Three user-visible bug classes resulted: (1) the vault button appeared dead on home/new-session โ€” the drawer's fetch is keyed on `useServer().current`, which is unset when no server is focused, so the resource never fired and the host rendered an empty shell; (2) the vault and column toggles' pressed states could disagree with the screen, since two buttons wrote into one column through two stores; (3) the column squished the chat, because upstream's `ReviewPanelV2` renders its own file-list sidebar plus diff pane inside the column. Deleting the second host deletes the desync class rather than renting it; routing diffs to full-width file tabs deletes the squish; keying the drawer's fetch on a fallback server deletes the silent empty host. + +**Conditions of merge (the hardening grafts):** `VaultBrowser` fetches against a fallback server (first healthy/connected) when none is focused; zero mounts render an attach-vault CTA that opens the Status Popover's connections tab; fetch failures render a named error with retry โ€” nothing fails silently, which was bug (1)'s true sin. The column's tab strip is Changes (default) / Context / file tabs; a changed-file click opens its diff as a full-width file tab via the existing tab mechanism. Column default width drops to ~320px (resize handle retained); the sidebar-right button's pressed state binds directly to `reviewPanel.opened()` โ€” the store has a single writer again. New i18n keys land in all 17 locales as English fallbacks (the merge's strict parity test demands it). Tests: `vaultPanel` store + tab-model unit tests updated (no vault tab); Storybook states for the column (loading / no-git / diffs / open diff tab); Playwright three-button flows across home, new-session, and session routes. + +**Flip condition:** if upstream gains a center editor surface for file viewing, revisit routing diffs there instead of full-width column tabs. If the vault ever needs per-project scoping (different mounts per directory), revisit the global drawer in favor of a directory-scoped host. + +**Accepted costs:** the in-session vault tab (2026-07-27) is removed weeks after landing โ€” vault loses its tab-local adjacency to file tabs, and the drawer overlays the left strip in-session, spatially distant from the top-right button that summons it. `session-side-panel.tsx` remains a high-conflict file on upstream syncs, now carrying deliberate deletions the next merger must not resurrect (the `createEffect` mirrors and the vault tab trigger/content blocks). + +**Considered:** minimal patch โ€” repair the two sync effects, keep both hosts (runner-up: smallest diff, but preserves two writers into one column and rents the next desync); full chrome rethink โ€” one panel system/store/host for every right-side surface (rejected: re-opens settled redesigns, blast radius spans just-merged upstream surface); session-tab-only vault with the titlebar button hidden elsewhere (rejected: vault unreachable from home/new-session, against the drawer's reason to exist). diff --git a/infra/console.ts b/infra/console.ts index 0a304a7be3..79556f5e0c 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 dc336a6843..cd2a96d9b3 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 dd11ca38f9..d4e46168e3 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 65ada2f1f6..2df3e60776 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 8d80eefed8..f0db797448 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 10a5fb20bc..10d37119f0 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", { @@ -183,7 +185,9 @@ 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", diff --git a/nix/desktop.nix b/nix/desktop.nix index d0d7fa7eca..2df62f7a1c 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 1bbea78c1b..2fafb82c44 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-nix3Ogrt4nFv+HSZpYZ3VqIQc+g1SdP+DSgu72Yjoqw=", - "aarch64-linux": "sha256-L634j49jyNoKhC1MDyF+Grxy++yhWgcbXsxKDgYhKAk=", - "aarch64-darwin": "sha256-ha692TeekwiV0irhIxdwE8/1x1bLKtzSwcjDvetiPqM=", - "x86_64-darwin": "sha256-JRwKof0cTFwOcOyLb6l+kF/HlCiFmMWzG1y3U1esyyM=" + "x86_64-linux": "sha256-SiJd6uXrL/MqqFGN/uUcHh0Wzdlafnpx++VZa5gUCoE=", + "aarch64-linux": "sha256-pKcT34NYIHVasraRTx0ASTzyuFuIzBTxXg6+KSKvTps=", + "aarch64-darwin": "sha256-hzrym0KpiyYAv80eT/DjcSIJWYBUq4QDgDf6Wtot7jU=", + "x86_64-darwin": "sha256-Or5dSTdajUwij0XpovXSXjJ0LmRUcpcW5TJ4q4B0k0A=" } } diff --git a/nix/opencode.nix b/nix/opencode.nix index 82a7b54c40..a22f7d3d24 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 8ea2430ac6..2a8eef6356 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": "17.0.6", "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,15 +144,20 @@ "@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" } } diff --git a/packages/app/AGENTS.md b/packages/app/AGENTS.md index 765e960c81..2e56066e7d 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. diff --git a/packages/app/V1_API_MIGRATION.md b/packages/app/V1_API_MIGRATION.md new file mode 100644 index 0000000000..2850f10740 --- /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/performance/AGENTS.md b/packages/app/e2e/performance/AGENTS.md new file mode 100644 index 0000000000..e69c91a98f --- /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 0000000000..ce868d573b --- /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 0000000000..b9f8ea4341 --- /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 0000000000..343526e254 --- /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 0000000000..d4793daee5 --- /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 0000000000..9097c11f1d --- /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 0000000000..3e9def2757 --- /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 0000000000..7ce1be9064 --- /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 0000000000..b3438b77c0 --- /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 0000000000..3455438a73 --- /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 0000000000..e0f0d72233 --- /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 0000000000..798bf0df3b --- /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 0000000000..b003645e64 --- /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 0000000000..df67da5a66 --- /dev/null +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -0,0 +1,566 @@ +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) { + await expect(page.locator(`[data-timeline-part-id="${partID}"]`).first()).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 0000000000..8cdf4fa8cf --- /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 0000000000..40688429a3 --- /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 0000000000..7891eba5bd --- /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 0000000000..cfa4d92613 --- /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 0000000000..e67545104d --- /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 0000000000..bab1ca23b4 --- /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 0000000000..03690ec921 --- /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 0000000000..d28fdaa65f --- /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 0000000000..e999e8c50a --- /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 0000000000..a07e8f3d89 --- /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 0000000000..db38678046 --- /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 0000000000..0e7ef45f2f --- /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 0000000000..9b74c7d6bf --- /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 0000000000..b8ec858e84 --- /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 0000000000..81de0a055f --- /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 0000000000..838af17c93 --- /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 0000000000..051b29d0cd --- /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 0000000000..862e080f13 --- /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 0000000000..48d4d3deb0 --- /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 0000000000..5298b9b0b7 --- /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 0000000000..955da80367 --- /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 0000000000..a86a55cff2 --- /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 0000000000..1a52bc8aa3 --- /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 0000000000..e1689498c1 --- /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 0000000000..a3cd698cde --- /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 0000000000..529081a1d9 --- /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 0000000000..5028373c55 --- /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 0000000000..456020ff30 --- /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 0000000000..c0e5474d6e --- /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 0000000000..83308c0a86 --- /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 0000000000..e22be67063 --- /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 0000000000..5d20c03a00 --- /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 0000000000..4b824d9dfb --- /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 0000000000..d5d7f09a69 --- /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 0000000000..f8fca1adb7 --- /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 0000000000..c0215c5cb6 --- /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 0000000000..98b661d91b --- /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 0000000000..2fc3be3166 --- /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 0000000000..f09a2c7b63 --- /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 0000000000..136232c211 --- /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 0000000000..45cd64adcc --- /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 0000000000..a17b8c0817 --- /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 0000000000..37739d2fbb --- /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/pane-boot-watch.spec.ts b/packages/app/e2e/regression/pane-boot-watch.spec.ts new file mode 100644 index 0000000000..7e481c372d --- /dev/null +++ b/packages/app/e2e/regression/pane-boot-watch.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +test("pane boot watch", async ({ page }) => { + test.setTimeout(120_000) + page.on("console", (m) => console.log("[frame-console]", m.text().slice(0, 200))) + page.on("pageerror", (e) => console.log("[pageerror]", String(e).slice(0, 300))) + await page.setViewportSize({ width: 1440, height: 900 }) + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + await page.addInitScript((directory) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] }, lastProject: { local: directory } }), + ) + }, fixture.directory) + await page.goto("/") + await expectAppVisible(page.getByText("Open chat").first()) + await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`) + const entry = page.locator("[data-workbench-panel] button", { hasText: fixture.expected.sourceTitle }).first() + const box = (await entry.boundingBox())! + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + await page.mouse.down() + await page.mouse.move(box.x + 40, 450, { steps: 6 }) + await page.mouse.move(1428, 450, { steps: 6 }) + await page.mouse.up() + await expect(page.locator("[data-pane]")).toHaveCount(1) + const frame = page.frameLocator("iframe[sandbox]").locator("body") + await expect.poll(async () => (await frame.innerText()).trim().length, { timeout: 30_000 }).toBeGreaterThan(0) + const text = await frame.innerText() + console.log("PANE BODY (first 400):", JSON.stringify(text.trim().slice(0, 400))) +}) 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 0000000000..50dcc8820b --- /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 d12684c143..9315c347c0 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 0000000000..35a0aa44cd --- /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 0000000000..2d9b1e2349 --- /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 0000000000..dd200384d4 --- /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 0000000000..7850f7820a --- /dev/null +++ b/packages/app/e2e/regression/review-line-comment.spec.ts @@ -0,0 +1,158 @@ +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() +}) + +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() +}) + +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) + + const from = await start.boundingBox() + const to = await end.boundingBox() + if (!from || !to) throw new Error("Missing line number bounds") + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2) + await page.mouse.down() + await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2) + await page.mouse.up() + + await expect(review.getByRole("textbox")).toBeVisible() +}) + +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 page.mouse.move(0, 0) + await lineNumber.hover() + await expect(comment).toBeVisible({ timeout: 500 }) + await comment.click({ timeout: 500 }) + }).toPass() + await expect(review.getByRole("textbox")).toBeVisible() +}) + +test("stages a submitted line comment in the prompt context", async ({ page }) => { + const requests: string[] = [] + page.on("request", (request) => { + if (request.method() !== "GET") requests.push(`${request.method()} ${new URL(request.url()).pathname}`) + }) + + const review = page.locator('[data-component="session-review"]') + await review.getByText("export const value = 'after'", { exact: true }).click() + await review.getByRole("textbox").fill("Use the existing value instead") + await review.locator('[data-slot="line-comment-action"][data-variant="primary"]').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") + expect(requests).toEqual([]) +}) + +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 diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff") + await page.getByRole("tab", { name: "Changes" }).click() + expect((await (await diffResponse).json()).data).toHaveLength(1) + + const review = page.locator('[data-component="session-review"]') + await expectAppVisible(review) + await review + .getByRole("heading", { name: /review\.ts/ }) + .getByRole("button") + .first() + .click() +} 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 0000000000..04e6d2cced --- /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 0000000000..0d6756201e --- /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 0000000000..8634166e51 --- /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 0000000000..79b564820e --- /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 4a3855122a..3319514df6 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 0000000000..5ea9d4f761 --- /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 0000000000..598763c022 --- /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 8b31b8cc27..5b6e0b127b 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 d2b1827573..a9a4738da9 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 0000000000..37878325e1 --- /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 0000000000..f07da121c6 --- /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 0000000000..cb228c13c7 --- /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 0000000000..e5ef7998ea --- /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 0000000000..b303071c87 --- /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 0000000000..3901f8865e --- /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: "Erkundet" }, + { 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 0000000000..b1aabcc32c --- /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 0000000000..7c0864e584 --- /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 0000000000..ad35eef601 --- /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 0000000000..54139cc371 --- /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 0000000000..99f1acf270 --- /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 0000000000..63646f4454 --- /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 0000000000..778ff3a3af --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-transport.spec.ts @@ -0,0 +1,116 @@ +import { expect, test } from "@playwright/test" +import { + assistantMessage, + partUpdated, + setupTimeline, + status, + 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) + expect(await timeline.transport.connections()).toHaveLength(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 timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])], + }) + const before = await page.locator("[data-timeline-row]").allTextContents() + + await timeline.transport.heartbeat() + await timeline.settle() + + expect(await page.locator("[data-timeline-row]").allTextContents()).toEqual(before) + expect(await timeline.transport.connections()).toHaveLength(1) +}) + +test("reconnects after a clean close", async ({ page }) => { + const timeline = await setupTimeline(page, { eventRetry: 10 }) + 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, { eventRetry: 10 }) + 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(status("busy")) + + 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, { eventRetry: 10, 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 }) + expect(await timeline.transport.connections()).toHaveLength(1) +}) 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 0000000000..55e7121275 --- /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 0000000000..019cc156ec --- /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 0000000000..b969b590d8 --- /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 0000000000..99bf689085 --- /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 0000000000..8e08d60ff2 --- /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 0000000000..165920753c --- /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/regression/workbench-drops.spec.ts b/packages/app/e2e/regression/workbench-drops.spec.ts new file mode 100644 index 0000000000..c7ba48bcff --- /dev/null +++ b/packages/app/e2e/regression/workbench-drops.spec.ts @@ -0,0 +1,197 @@ +import { test, expect, type Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +// ============================================================================ +// Workbench drops โ€” S2 gate (spec-20260731-110956-workbench-panes-refactor): +// accidental_splits == 0 (sloppy drags change nothing) +// drop_targets_are_explicit == 1 (only rails/strips/panel accept; content rejects) +// duplicate_or_lost_tabs_after_move == 0 (incl. fault injection: dead pane boot) +// ============================================================================ + +const sourceRoute = `/${base64Encode(fixture.directory)}/session/${fixture.sourceID}` +const targetRoute = `/${base64Encode(fixture.directory)}/session/${fixture.targetID}` + +async function boot(page: Page) { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + await page.addInitScript((directory) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + }, fixture.directory) + await page.goto("/") + await expectAppVisible(page.getByText("Open chat").first()) +} + +/** HTML5 drag helper: from a locator's center to (x, y). */ +async function drag(page: Page, from: ReturnType, to: { x: number; y: number }) { + const box = (await from.boundingBox())! + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + await page.mouse.down() + await page.mouse.move(box.x + box.width / 2 + 20, box.y + 60, { steps: 6 }) + await page.mouse.move(to.x, to.y, { steps: 10 }) + await page.mouse.up() +} + +const mainStripTabs = (page: Page) => page.locator("[data-split-main] .no-scrollbar div[data-active], [data-split-main] .no-scrollbar .tab") +const stripTabCount = (page: Page) => page.locator(".no-scrollbar div[data-active]").count() +const paneFrame = (page: Page) => page.frameLocator("iframe[sandbox]") + +/** Split via panel: drag the source session's panel entry onto the right rail. */ +async function splitFromPanel(page: Page) { + const entry = page.locator("[data-workbench-panel] button", { hasText: fixture.expected.sourceTitle }).first() + await expect(entry).toBeVisible() + await drag(page, entry, { x: page.viewportSize()!.width - 12, y: 450 }) + await expect(page.locator("[data-pane]")).toHaveCount(1) + // the pane boots its own app instance โ€” slow in dev (~8s cold vite). Its + // strip rendering doubles as the drop model's boot-complete signal (its + // first tabs-changed report to the mirror lands in the same boot). + await expect(paneFrame(page).locator(".no-scrollbar div[data-active]").first()).toBeVisible({ timeout: 25_000 }) +} + +test.beforeEach(async ({ page }) => { + test.setTimeout(30_000) + page.on("pageerror", (e) => console.log("[pageerror]", String(e).slice(0, 300))) + page.on("console", (m) => { + if (m.type() === "error" || m.text().includes("workbench")) console.log("[console]", m.text().slice(0, 200)) + }) + await page.setViewportSize({ width: 1440, height: 900 }) +}) + +test("S2.1 accidental_splits == 0: a sloppy drag into content changes nothing", async ({ page }) => { + await boot(page) + await page.goto(sourceRoute) + const tab = page.locator(".no-scrollbar div[data-active]").first() + await expect(tab).toBeVisible() + const before = await stripTabCount(page) + await drag(page, tab, { x: 400, y: 500 }) + await expect(page.locator("[data-pane]")).toHaveCount(0) + expect(await stripTabCount(page)).toBe(before) +}) + +test("S2.2 drop_targets_are_explicit: the main content area rejects drops", async ({ page }) => { + await boot(page) + await page.goto(targetRoute) + await splitFromPanel(page) + const tab = page.locator(".no-scrollbar div[data-active]").first() + const before = await stripTabCount(page) + // dead-center of the main content โ€” not a strip, not a rail, not the panel + await drag(page, tab, { x: 720, y: 620 }) + expect(await stripTabCount(page)).toBe(before) + // and the pane keeps its own single tab + await expect(paneFrame(page).locator(".no-scrollbar div[data-active]")).toHaveCount(1) +}) + +test("S2.3 panel entries are drag sources: panel โ†’ right rail splits with that session", async ({ page }) => { + await boot(page) + await page.goto(targetRoute) + await splitFromPanel(page) + // the pane shows the dragged session's tab + await expect(paneFrame(page).locator(".no-scrollbar div[data-active]")).toHaveCount(1) + // and the main strip is untouched + expect(await stripTabCount(page)).toBe(1) +}) + +test("S2.4 move main โ†’ pane: the tab leaves the main strip and activates in the pane", async ({ page }) => { + await boot(page) + // main strip holds only the TARGET tab; the source session splits from the panel + await page.goto(targetRoute) + expect(await stripTabCount(page)).toBe(1) + await splitFromPanel(page) + + // drag the main tab (target session) onto the pane's strip region + const tab = page.locator(".no-scrollbar div[data-active]").first() + const paneBox = (await page.locator("[data-pane]").boundingBox())! + await drag(page, tab, { x: paneBox.x + paneBox.width / 2, y: paneBox.y + 18 }) + + // the target tab left the main strip; the pane now holds source + target + expect(await stripTabCount(page)).toBe(0) + await expect(paneFrame(page).locator(".no-scrollbar div[data-active]")).toHaveCount(2) + // the moved session is the pane's visible content + await expect(paneFrame(page).getByText(fixture.expected.targetTitle).first()).toBeVisible() +}) + +test("S2.5 dock pane โ†’ main strip: the tab comes home", async ({ page }) => { + await boot(page) + await page.goto(targetRoute) + await splitFromPanel(page) + + // drag the pane's tab back onto the main strip. NOTE: synthetic (CDP) + // drags don't initiate HTML5 DnD inside the sandboxed pane iframe โ€” a + // platform limit, not app behavior. So the test exercises the pane's OWN + // wiring seam-for-seam: dispatch dragstart on the pane's tab (fires its + // wired listener โ†’ posts drag-tab-start to the parent), then drop on the + // main strip (fires the parent's resolution โ†’ move executes). + const paneTab = paneFrame(page).locator(".no-scrollbar div[data-active]").first() + await expect(paneTab).toBeVisible() + const path = await paneTab.evaluate((el) => { + const href = el.querySelector("a")?.getAttribute("href") ?? "" + el.dispatchEvent(new DragEvent("dragstart", { bubbles: true, cancelable: true })) + return href + }) + expect(path).toContain("/session/") + + const mainStrip = page.locator("[data-split-main] .no-scrollbar").first() + const stripBox = (await mainStrip.boundingBox())! + await mainStrip.evaluate((el, box) => { + const row = el.closest("div.flex-row") ?? el + row.dispatchEvent( + new DragEvent("dragover", { bubbles: true, cancelable: true, clientX: box.x + 120, clientY: box.y + box.height / 2 }), + ) + row.dispatchEvent( + new DragEvent("drop", { bubbles: true, cancelable: true, clientX: box.x + 120, clientY: box.y + box.height / 2 }), + ) + }, stripBox) + + await expect.poll(() => stripTabCount(page), { timeout: 5000 }).toBe(2) +}) + +test("S2.6 same-session singleton: dropping an already-open session focuses it, never duplicates", async ({ page }) => { + await boot(page) + await page.goto(sourceRoute) + // the source session is open in main; drag its PANEL entry to the right rail + const entry = page.locator("[data-workbench-panel] button", { hasText: fixture.expected.sourceTitle }).first() + await drag(page, entry, { x: page.viewportSize()!.width - 12, y: 450 }) + await page.waitForTimeout(800) + // no split happened โ€” the existing tab was focused instead + await expect(page.locator("[data-pane]")).toHaveCount(0) + expect(await stripTabCount(page)).toBe(1) +}) + +test("S2.7 fault injection: a pane that never boots cannot swallow a tab", async ({ page }) => { + await boot(page) + // main shows the TARGET session โ€” the SOURCE session (dragged below) must + // not already be open, or the rail singleton gate focuses instead of splitting + await page.goto(targetRoute) + // block the pane iframe's app document โ€” the pane boots empty and the + // self-heal guard will merge it; a dropped tab must survive in main. + await page.route("**/*", (route) => { + const url = route.request().url() + if (url.includes("amicode_pane=")) return route.abort() + return route.fallback() + }) + const entry = page.locator("[data-workbench-panel] button", { hasText: fixture.expected.sourceTitle }).first() + await drag(page, entry, { x: page.viewportSize()!.width - 12, y: 450 }) + await expect(page.locator("[data-pane]")).toHaveCount(1) + + // drag the main tab toward the dead pane's strip region + const tab = page.locator(".no-scrollbar div[data-active]").first() + const paneBox = (await page.locator("[data-pane]").boundingBox())! + await drag(page, tab, { x: paneBox.x + paneBox.width / 2, y: paneBox.y + 18 }) + + // nothing was lost into the void: the tab is still open in main + expect(await stripTabCount(page)).toBe(1) + await expect(page.locator(".no-scrollbar div[data-active]")).toHaveCount(1) +}) diff --git a/packages/app/e2e/regression/workbench-panes.spec.ts b/packages/app/e2e/regression/workbench-panes.spec.ts new file mode 100644 index 0000000000..10c4b16e99 --- /dev/null +++ b/packages/app/e2e/regression/workbench-panes.spec.ts @@ -0,0 +1,154 @@ +import { test, expect, type Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +// ============================================================================ +// Workbench panes โ€” S1 gate (spec-20260731-110956-workbench-panes-refactor): +// main_column_width_preserved == 1 +// content_baseline_offset_px <= 2 +// tab_labels_truncate_cleanly == 1 +// sessions_panel_hideable == 1 +// Every metric is asserted headlessly; failure screenshots land in +// e2e/test-results (the runner archives them). +// ============================================================================ + +const sessionRoute = `/${base64Encode(fixture.directory)}/session/${fixture.sourceID}` + +async function bootTwoSessions(page: Page) { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + // Seed the project into the persisted server store (the app otherwise boots + // to the empty-projects home and the sessions never surface). + await page.addInitScript((directory) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + }, fixture.directory) + await page.goto("/") + // This build's home is the dashboard โ€” it never lists session titles, so + // readiness is the dashboard itself; session tabs appear once routed to. + await expectAppVisible(page.getByText("Open chat").first()) +} + +/** Drag the first strip tab onto the right edge rail โ€” the split gesture. */ +async function splitViaRightRail(page: Page) { + const tab = page.locator(".no-scrollbar div[data-active]").first() + await expect(tab).toBeVisible() + const box = (await tab.boundingBox())! + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + await page.mouse.down() + await page.mouse.move(box.x + box.width / 2 + 40, 450, { steps: 10 }) + await page.mouse.move(page.viewportSize()!.width - 12, 450, { steps: 10 }) + await page.mouse.up() + await expect(page.locator("[data-pane]")).toHaveCount(1) +} + +const paneFrame = (page: Page) => page.frameLocator("iframe[sandbox]") + +test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) +}) + +test("S1.1 main_column_width_preserved: main keeps its floor and the row fills the window", async ({ page }) => { + await bootTwoSessions(page) + await page.goto(sessionRoute) + await expect(page.locator(".no-scrollbar div[data-active]").first()).toBeVisible() + + await splitViaRightRail(page) + + const main = await page.locator("[data-split-main]").boundingBox() + expect(main).not.toBeNull() + expect(main!.width).toBeGreaterThanOrEqual(280) + + // the row fills the window width โ€” no dead space after the pane (FM2) + const row = await page.locator("div.h-full.w-full.min-h-0.min-w-0.flex.flex-row").boundingBox() + expect(row).not.toBeNull() + expect(Math.abs(row!.width - 1440)).toBeLessThanOrEqual(2) + + // sash drag keeps the floor + const sash = page.locator(".cursor-col-resize").first() + const sb = (await sash.boundingBox())! + await page.mouse.move(sb.x + sb.width / 2, sb.y + sb.height / 2) + await page.mouse.down() + await page.mouse.move(sb.x - 200, sb.y + sb.height / 2, { steps: 6 }) + await page.mouse.up() + const main2 = await page.locator("[data-split-main]").boundingBox() + expect(main2!.width).toBeGreaterThanOrEqual(280) + + // merge restores the single full-width view + await page.locator("button[aria-label='Merge pane back']").click() + await expect(page.locator("[data-pane]")).toHaveCount(0) +}) + +test("S1.2 content_baseline_offset_px <= 2: both sides' chrome and content align", async ({ page }) => { + await bootTwoSessions(page) + await page.goto(sessionRoute) + await expect(page.locator(".no-scrollbar div[data-active]").first()).toBeVisible() + await splitViaRightRail(page) + + // both titlebar strips at the same height + const mainStrip = await page.locator(".no-scrollbar").first().boundingBox() + const paneStrip = await paneFrame(page).locator(".no-scrollbar").first().boundingBox() + expect(mainStrip).not.toBeNull() + expect(paneStrip).not.toBeNull() + expect(Math.abs(mainStrip!.y - paneStrip!.y)).toBeLessThanOrEqual(2) + + // session headers (the content's first heading) at the same height + const mainHeader = await page.getByText(fixture.expected.sourceTitle).first().boundingBox() + const paneHeader = await paneFrame(page).getByText(fixture.expected.sourceTitle).first().boundingBox() + expect(mainHeader).not.toBeNull() + expect(paneHeader).not.toBeNull() + expect(Math.abs(mainHeader!.y - paneHeader!.y)).toBeLessThanOrEqual(2) +}) + +test("S1.3 tab_labels_truncate_cleanly: pane tab labels ellipsize, never clip mid-glyph", async ({ page }) => { + await bootTwoSessions(page) + // the long-titled session exercises truncation + await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`) + await expect(page.locator(".no-scrollbar div[data-active]").first()).toBeVisible() + await splitViaRightRail(page) + + const paneTabLabel = paneFrame(page).locator(".no-scrollbar div[data-active] a span.truncate").first() + await expect(paneTabLabel).toBeVisible() + const metrics = await paneTabLabel.evaluate((el) => { + const cs = getComputedStyle(el) + return { textOverflow: cs.textOverflow, overflow: cs.overflow, whiteSpace: cs.whiteSpace, clipped: el.scrollWidth > el.clientWidth } + }) + expect(metrics.textOverflow).toBe("ellipsis") + expect(metrics.overflow).toBe("hidden") + expect(metrics.whiteSpace).toBe("nowrap") + // truncation IS happening (content exceeds the box) and CSS handles it โ€” no mid-glyph clip + expect(metrics.clipped).toBe(true) +}) + +test("S1.4 sessions_panel_hideable: the workbench panel hides, persists across reload, and returns", async ({ page }) => { + await bootTwoSessions(page) + await page.goto(sessionRoute) + + // the workbench sessions panel shows on session routes, listing sessions + const panel = page.locator("[data-workbench-panel]") + await expect(panel).toBeVisible() + await expect(panel.getByText(fixture.expected.sourceTitle).first()).toBeVisible() + + const sidebarToggle = page.getByRole("button", { name: "Toggle sidebar" }) + await sidebarToggle.click() + await expect(panel).toBeHidden() + + await page.goto(sessionRoute) + await expect(page.locator(".no-scrollbar div[data-active]").first()).toBeVisible() + await expect(panel).toBeHidden() + + await sidebarToggle.click() + await expect(panel).toBeVisible() +}) 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 0000000000..995008a9f7 --- /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 0000000000..cdcd809be3 --- /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 0000000000..7fff1737ae --- /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 0000000000..3edae33a9b --- /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 0000000000..efb75ab731 --- /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 1fc8571db4..3dce37cafd 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 e4add848a4..e2d4bdc15a 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, }, }), ) @@ -426,6 +726,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 3f1cad80cb..4a6046e4fa 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 0000000000..22b8bb41fe --- /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 9a03a9d5ad..76987421b6 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 0000000000..b0e3b74c6d --- /dev/null +++ b/packages/app/e2e/utils/sse-transport.ts @@ -0,0 +1,312 @@ +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 = {}) { + await page.waitForFunction( + (after) => { + const transport = (window as BrowserTransport).__testSseTransport + const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined + return connections?.some((connection) => connection.id > after) + }, + input.after ?? 0, + { timeout: input.timeout }, + ) + return (await command({ type: "connections" })).findLast( + (connection) => connection.id > (input.after ?? 0), + )! + }, + 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 0000000000..2c56864ed8 --- /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 0000000000..902d222e1d --- /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 0000000000..3bb583a9d5 --- /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 0000000000..6fdbf4acfc --- /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 0000000000..17f6ef1913 --- /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 0000000000..1b966638c9 --- /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 0000000000..abf274d359 --- /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 0000000000..4c0b34e78a --- /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 0000000000..db283072a8 --- /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 0000000000..2136302071 --- /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 1c6a6a550d..bb0d1e3bc4 100644 --- a/packages/app/index.html +++ b/packages/app/index.html @@ -1,22 +1,28 @@ - + - + Amicode - + + + + - + -
+
diff --git a/packages/app/package.json b/packages/app/package.json index 74327923d7..e6e95e35ff 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.17.3", + "version": "1.18.10", "description": "", "type": "module", "exports": { @@ -13,17 +13,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:unit": "bun test --only-failures --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": { @@ -36,16 +40,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", @@ -63,11 +77,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:", @@ -75,7 +90,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 9b25311a8c..09707534b7 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -9,6 +9,7 @@ const reuse = !process.env.CI const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 2 : 0)) || undefined export default defineConfig({ testDir: "./e2e", + testIgnore: process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**", outputDir: "./e2e/test-results", timeout: 60_000, expect: { diff --git a/packages/app/public/oc-theme-preload.js b/packages/app/public/oc-theme-preload.js index 51ca5f1481..0ab9742019 100644 --- a/packages/app/public/oc-theme-preload.js +++ b/packages/app/public/oc-theme-preload.js @@ -26,10 +26,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 6828e60f84..d45af34c43 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 3823fb443a..515153488c 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 51f819f2aa..000030ca8f 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -5,18 +5,29 @@ 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, useTheme } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" -import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } 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, @@ -28,101 +39,199 @@ 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 { SplitProvider } from "@/context/split" +import { WorkbenchProvider } from "@/context/workbench" 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, useTabs, type DraftTab } from "@/context/tabs" import { SDKProvider, useSDK } from "@/context/sdk" import { WslServersProvider } from "@/wsl/context" import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout" -import Layout from "@/pages/layout" +import LegacyLayout from "@/pages/layout" +import NewLayout from "@/pages/layout-new" import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" import { AmicodeSplash } from "@opencode-ai/ui/amicode-splash" +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 HomeRoute = lazy(() => import("@/pages/home")) -const Session = lazy(() => import("@/pages/session")) const NewSession = lazy(() => import("@/pages/new-session")) -const SessionRoute = Object.assign( - () => { - const settings = useSettings() - const params = useParams() - const [search] = useSearchParams<{ draftId?: string; prompt?: string }>() - const sdk = useSDK() - const server = useServer() - const tabs = useTabs() - - // 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 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 + }} + ) - }, - { preload: Session.preload }, + } + + // 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) + }) + + return ( + + + + ) +} + +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 ( - }> - {(draftID) => } + tab.type === "draft" && tab.draftID === search.draftId)} + keyed + fallback={} + > + {(draft) => ( + } + > + + + )} ) } -function ResolvedDraftRoute(props: { draftID: string }) { - const server = useServer() - const tabs = useTabs() - const draft = createMemo(() => - tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID), - ) - - createEffect(() => { - const current = draft() - if (current && current.server !== server.key) server.setActive(current.server) - }) - - // Key on the directory so retargeting the draft's project re-instantiates the - // SDK/data providers for the new directory while keeping the same draft id. - const directory = () => draft()?.directory +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 ( - - {(dir) => ( - - - - - - - - )} + + + + + + + + + + + + + + ) } @@ -132,13 +241,37 @@ function UiI18nBridge(props: ParentProps) { 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 { interface Window { __OPENCODE__?: { deepLinks?: string[] } api?: { - setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise + setTitlebar?: (theme: { mode: "light" | "dark"; scheme?: "system" | "light" | "dark" }) => Promise exportDebugLogs?: () => Promise } } @@ -160,10 +293,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) @@ -173,36 +307,81 @@ 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} - - - - - - - + + + + {/* amicode(split): the in-app pane state wraps the shell โ€” the + titlebar (drag sources) and the layout (drop zones) both + consume it. amicode(workbench S2): the parent's tab mirror + sits above it โ€” drops resolve against the mirror. */} + + {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: "Export logs", + 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 LegacyServerScopedShell(props: ServerScopedShellProps) { + return ( + + {props.children} + ) } -function SessionProviders(props: ParentProps) { +function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) { return ( - - - - {props.children} - - - + + + {props.children} + + ) } @@ -218,17 +397,6 @@ function DraftProviders(props: ParentProps) { ) } -function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { - return ( - - {/*}>*/} - {props.appChildren} - {props.children} - {/**/} - - ) -} - /** amicode: live theme bridge for the VS Code webview host. The extension * forwards editor theme changes as window messages (outer relay โ†’ iframe); * route them through the existing setColorScheme so everything re-themes. */ @@ -271,8 +439,8 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { { - void window.api?.setTitlebar?.({ mode }) + onThemeApplied={(_, mode, scheme) => { + void window.api?.setTitlebar?.({ mode, scheme }) }} > @@ -301,7 +469,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() @@ -330,34 +498,46 @@ 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} + + + + {/* amicode: brand splash at the loading surface */} +
- } - > - { - if (checkMode() === "background") void healthCheckActions.refetch() - }} - onServerSelected={(key) => { - setCheckMode("blocking") - server.setActive(key) - void healthCheckActions.refetch() - }} - /> - } - > - {props.children} -
+ ) } @@ -424,7 +604,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 0000000000..aeca6d977a 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 0000000000..fd46be1f46 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 0000000000..c651d06cc9 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 0000000000..d2c6e68a3d 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 0000000000..8014ea1c43 --- /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 0000000000..63c7f32468 --- /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 0000000000..d6685b95a8 --- /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/connection-banner.tsx b/packages/app/src/components/connection-banner.tsx new file mode 100644 index 0000000000..0cf570dd01 --- /dev/null +++ b/packages/app/src/components/connection-banner.tsx @@ -0,0 +1,75 @@ +import { Show, createEffect, createSignal, onCleanup } from "solid-js" +import { useLanguage } from "@/context/language" +import { useServerSDK } from "@/context/server-sdk" + +// Amicode webview: a dead server (e.g. an opencode:build restart) used to read +// as an endless "thinking" wave โ€” the event loop retries silently every 250ms. +// Surface the drop and the recovery. The fresh stream's server.connected makes +// server-sync refetch everything, so recovery needs no reload in most cases. +export function ConnectionBanner() { + const sdk = useServerSDK() + const language = useLanguage() + const [connectedOnce, setConnectedOnce] = createSignal(false) + const [wasDown, setWasDown] = createSignal(false) + const [flash, setFlash] = createSignal(false) + let flashTimer: ReturnType | undefined + onCleanup(() => { + if (flashTimer !== undefined) clearTimeout(flashTimer) + }) + + createEffect(() => { + const status = sdk().event.status() + if (status === "disconnected") { + // Only report drops after a first successful connect โ€” boot renders + // "disconnected" transiently while the stream is still being set up. + if (connectedOnce()) setWasDown(true) + if (flashTimer !== undefined) { + clearTimeout(flashTimer) + flashTimer = undefined + } + setFlash(false) + return + } + if (!connectedOnce()) { + setConnectedOnce(true) + return + } + if (wasDown()) { + setWasDown(false) + setFlash(true) + if (flashTimer !== undefined) clearTimeout(flashTimer) + flashTimer = setTimeout(() => { + setFlash(false) + flashTimer = undefined + }, 3000) + } + }) + + const down = () => wasDown() && sdk().event.status() === "disconnected" + + return ( + +
+ {down() ? language.t("app.server.connectionLost") : language.t("app.server.reconnected")} +
+
+ ) +} 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 0000000000..a4019928aa --- /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 0000000000..e996fd0be7 --- /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 0000000000..3aec6c9de3 --- /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 4d477ea273..0267b4b4ef 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,20 @@ 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 ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "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 +546,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 +564,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 +704,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 +717,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 +770,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 +791,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 +816,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 +886,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 +901,8 @@ export function DialogConnectProvider(props: { provider: string }) {
{ + if (!newLayout()) return + codeInput?.focus({ preventScroll: true }) + }) + async function handleSubmit(e: SubmitEvent) { e.preventDefault() @@ -480,13 +946,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 +962,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 38690bb241..e34e4c39b8 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("Custom providers are unavailable on this server") + 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 0000000000..dc9714aa06 --- /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 29ee4dfeab..86a9630359 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 d0d105e078..5187d980ea 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 b46034bbad..d6c5e99186 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 0000000000..e1d22f19c6 --- /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 0000000000..f3376ad35f --- /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 4eb5ba4b58..96bcedb9db 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 @@ -24,89 +24,9 @@ type Row = { 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 +48,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 +75,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(() => { @@ -336,7 +143,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 +152,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 +161,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 +176,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 4080c8ce85..905abe1e2d 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -1,321 +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 - -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) - } - 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 } -} +export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { + const platform = usePlatform() + const settings = useSettings() + const filesOnly = () => props.mode === "files" -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() && 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 + if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) { + return } - return { sessions } + return } -export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (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 @@ -323,86 +83,46 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil } 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 handleMove = (item: Entry | undefined) => { - state.cleanup?.() - if (!item) return - if (item.type !== "command") return - state.cleanup = item.option?.onHighlight?.() + 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 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 - 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)}
@@ -456,7 +176,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
- {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 176817bfd1..4f1a3cd239 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 0000000000..3ee8009140 --- /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 0000000000..901f3fc114 --- /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 0000000000..bd4beb2ec9 --- /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 0000000000..2d0c3a7305 --- /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 fae743bb81..4611a36c95 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 ca2643c3b5..9066f72434 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 89310286da..0000000000 --- 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 0876906890..aa16976228 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 20d71f4bfd..b554fa79a8 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 e428d4c2bb..bf5da751e2 100644 --- a/packages/app/src/components/dialog-usage-exceeded.tsx +++ b/packages/app/src/components/dialog-usage-exceeded.tsx @@ -17,7 +17,7 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) { const platform = usePlatform() const runAction = () => { - if (props.link) platform.openLink(props.link) + if (props.link) platform.openExternal(props.link) props.onClose?.() dialog.close() } diff --git a/packages/app/src/components/directory-picker-domain.test.ts b/packages/app/src/components/directory-picker-domain.test.ts new file mode 100644 index 0000000000..1bc9af0833 --- /dev/null +++ b/packages/app/src/components/directory-picker-domain.test.ts @@ -0,0 +1,248 @@ +import { expect, test } from "bun:test" +import { + absoluteTreePath, + activeTreeNavigation, + advanceTreePreload, + nextSuggestionIndex, + nextTreeScrollTop, + pickerTreeEntries, + pickerSearchEntries, + pickerFileSearchQuery, + pickerMode, + preloadTreeDirectories, + selectedTreePath, + treeEntries, + treePathWithin, + currentPickerSuggestions, + createDirectorySearch, + createPriorityTaskQueue, + displayPickerPath, + pickerParent, + pickerRoot, + pickerAbsoluteInput, +} from "./directory-picker-domain" + +test("maps server directory entries into Pierre paths", () => { + expect( + treeEntries("src/", [ + { name: "components", type: "directory" }, + { name: "index.ts", type: "file" }, + ]), + ).toEqual(["src/components/", "src/index.ts"]) +}) + +test("maps Pierre paths back to the selected server root", () => { + expect(absoluteTreePath("C:/Users/luke", "src/components/")).toBe("C:/Users/luke/src/components") + expect(absoluteTreePath("C:/", "")).toBe("C:/") + expect(absoluteTreePath("C:/", "README.md")).toBe("C:/README.md") + expect(absoluteTreePath("/home/luke", "README.md")).toBe("/home/luke/README.md") +}) + +test("includes files only when the picker selects files", () => { + const nodes = [ + { name: "components", type: "directory" as const }, + { name: "index.ts", type: "file" as const }, + ] + expect(pickerTreeEntries("", nodes, "directory")).toEqual(["components/"]) + expect(pickerTreeEntries("", nodes, "file")).toEqual(["components/", "index.ts"]) +}) + +test("includes files in file autocomplete while preserving directory navigation", () => { + const nodes = [ + { name: "src", absolute: "/repo/src", type: "directory" as const }, + { name: "README.md", absolute: "/repo/README.md", type: "file" as const }, + ] + expect(pickerSearchEntries(nodes, "directory")).toEqual([nodes[0]]) + expect(pickerSearchEntries(nodes, "file")).toEqual(nodes) +}) + +test("centralizes file and directory selection policy", () => { + const file = pickerMode("file", "/repo") + expect(file.includeFiles).toBeTrue() + expect(file.selection("/repo/src", "index.ts")).toBe("src/index.ts") + expect(file.selection("/repo", "src/")).toBeUndefined() + expect(file.result("/repo", "src/index.ts")).toBe("src/index.ts") + expect(file.selection("/tmp", "example.txt")).toBeUndefined() + expect(file.navigation("/repo/src")).toBe("/repo/src") + expect(file.navigation("/tmp")).toBeUndefined() + + const directory = pickerMode("directory") + expect(directory.includeFiles).toBeFalse() + expect(directory.selection("/repo", "src/")).toBe("/repo/src") + expect(directory.selection("C:/Users/luke", "repos/")).toBe("C:\\Users\\luke\\repos") + expect(directory.selection("//Server/Share", "repo/")).toBe("\\\\Server\\Share\\repo") + expect(directory.navigation("/tmp")).toBe("/tmp") + expect(directory.result("/repo", "")).toBe("/repo") + expect(directory.result("C:/Users/luke", "")).toBe("C:\\Users\\luke") + expect(directory.result("//Server/Share/repo", "")).toBe("\\\\Server\\Share\\repo") + expect(directory.result("/repo", "", false)).toBeUndefined() +}) + +test("accepts mutations only from the active navigation", () => { + expect(activeTreeNavigation(3, 3)).toBeTrue() + expect(activeTreeNavigation(2, 3)).toBeFalse() +}) + +test("preserves POSIX case while matching Windows drives case-insensitively", () => { + expect(treePathWithin("/repo", "/Repo")).toBeFalse() + expect(treePathWithin("C:/Repo", "c:/repo/src")).toBeTrue() + expect(treePathWithin("//Server/Share/Repo", "//server/share/repo/src")).toBeTrue() + expect(pickerMode("file", "//Server/Share/Repo").selection("//server/share/repo/src", "file.ts")).toBe("src/file.ts") + expect(treePathWithin("/repo", "/repo/../tmp")).toBeFalse() + expect(treePathWithin("/", "/src")).toBeTrue() + expect(pickerMode("file", "C:/Repo").selection("c:/repo/src", "file.ts")).toBe("src/file.ts") + expect(pickerMode("file", "C:/").selection("C:/", "file.ts")).toBe("file.ts") +}) + +test("displays paths using the selected server path format", () => { + expect(displayPickerPath("C:/Users/luke/repos", "C:/Users/luke/repos", "C:/Users/luke")).toBe( + "C:\\Users\\luke\\repos", + ) + expect(displayPickerPath("C:/Users/luke/repos", "C:\\Users\\luke\\repos", "C:/Users/luke")).toBe( + "C:\\Users\\luke\\repos", + ) + expect(displayPickerPath("/home/luke/repos", "repos", "/home/luke")).toBe("~/repos") + expect(displayPickerPath("/home/luke/repos", "~/repos", "/home/luke")).toBe("~/repos") +}) + +test("treats the server share prefix as the UNC root", () => { + expect(pickerRoot("//Server/Share/repo/src")).toBe("//Server/Share") + expect(pickerRoot("\\\\Server\\Share\\repo\\src")).toBe("//Server/Share") + expect(pickerParent("//Server/Share")).toBe("//Server/Share") + expect(pickerParent("//Server/Share/repo")).toBe("//Server/Share") +}) + +test("resolves relative input against the current picker root", () => { + expect(pickerAbsoluteInput("src", "/home/luke", "/home/luke/repo")).toBe("/home/luke/repo/src") + expect(pickerAbsoluteInput("../other", "/home/luke", "/home/luke/repo")).toBe("/home/luke/other") + expect(pickerAbsoluteInput("~/.config", "/home/luke", "/home/luke/repo")).toBe("/home/luke/.config") + expect(pickerAbsoluteInput("src", "C:/Users/luke", "C:/Users/luke/repo")).toBe("C:/Users/luke/repo/src") +}) + +test("exposes autocomplete results only for their source query", () => { + const result = { query: "/repo/src", items: ["/repo/src/index.ts"] } + expect(currentPickerSuggestions(result, "/repo/src")).toEqual(result.items) + expect(currentPickerSuggestions(result, "/repo/test")).toEqual([]) +}) + +test("scopes file autocomplete to the current browser root", () => { + expect(pickerFileSearchQuery("/home/luke/repos", "/home/luke/repos/src/in", "/home/luke")).toBe("src/in") + expect(pickerFileSearchQuery("/home/luke", "~/repos/op", "/home/luke")).toBe("repos/op") +}) + +test("resolves directory autocomplete from the current browser root", async () => { + const directories: string[] = [] + const sdk = { + api: { + file: { + find: (input: { location?: { directory?: string } }) => { + directories.push(input.location?.directory ?? "") + return Promise.resolve({ data: [] }) + }, + }, + }, + } as unknown as Parameters[0]["sdk"] + let base = "/repo" + const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base }) + + await search("components") + base = "/repo/src" + await search("components") + + expect(directories).toEqual(["/repo", "/repo/src"]) +}) + +test("searches from an absolute root without a default base", async () => { + const directories: string[] = [] + const sdk = { + api: { + file: { + list: (input: { location?: { directory?: string } }) => { + directories.push(input.location?.directory ?? "") + return Promise.resolve({ + data: [ + { path: "Users/", type: "directory" }, + { path: "tmp/", type: "directory" }, + ], + }) + }, + }, + }, + } as unknown as Parameters[0]["sdk"] + const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined }) + + expect(await search("/")).toEqual(["/Users", "/tmp"]) + expect(directories).toEqual(["/"]) +}) + +test("identifies the next directory level to preload", () => { + expect( + preloadTreeDirectories("src/", [ + { name: "components", type: "directory" }, + { name: "index.ts", type: "file" }, + { name: "utils", type: "directory" }, + ]), + ).toEqual(["src/components/", "src/utils/"]) +}) + +test("advances preloading once for every expanded directory", () => { + const advanced = new Set() + expect(advanceTreePreload(advanced, "")).toBeTrue() + expect(advanceTreePreload(advanced, "")).toBeFalse() + expect(advanceTreePreload(advanced, "repos/")).toBeTrue() +}) + +test("limits background tasks and prioritizes newly requested work", async () => { + const queue = createPriorityTaskQueue(2) + const first = Promise.withResolvers() + const second = Promise.withResolvers() + const started: string[] = [] + let active = 0 + let maximum = 0 + const task = (name: string, blocker?: Promise) => async () => { + started.push(name) + active++ + maximum = Math.max(maximum, active) + await blocker + active-- + } + + const running = [ + queue.schedule("first", "background", task("first", first.promise)), + queue.schedule("second", "background", task("second", second.promise)), + queue.schedule("preload", "background", task("preload")), + queue.schedule("opened", "user", task("opened")), + ] + await Promise.resolve() + expect(started).toEqual(["first", "second"]) + + first.resolve() + await running[0] + await Promise.resolve() + expect(started).toEqual(["first", "second", "opened"]) + + second.resolve() + await Promise.all(running) + expect(started).toEqual(["first", "second", "opened", "preload"]) + expect(maximum).toBe(2) +}) + +test("clamps bridged tree wheel scrolling", () => { + expect(nextTreeScrollTop(100, 40, 500, 200)).toBe(140) + expect(nextTreeScrollTop(10, -40, 500, 200)).toBe(0) + expect(nextTreeScrollTop(290, 40, 500, 200)).toBe(300) +}) + +test("wraps autocomplete keyboard navigation", () => { + expect(nextSuggestionIndex(-1, 1, 4)).toBe(0) + expect(nextSuggestionIndex(3, 1, 4)).toBe(0) + expect(nextSuggestionIndex(0, -1, 4)).toBe(3) + expect(nextSuggestionIndex(0, 1, 0)).toBe(-1) +}) + +test("returns absolute directories and relative files", () => { + expect(selectedTreePath("/home/luke/repo", "src/", "directory")).toBe("/home/luke/repo/src") + expect(selectedTreePath("/home/luke/repo", "src/index.ts", "file")).toBe("src/index.ts") + expect(selectedTreePath("/home/luke/repo/src", "index.ts", "file", "/home/luke/repo")).toBe("src/index.ts") + expect(selectedTreePath("/home/luke/repo", "src/", "file")).toBeUndefined() +}) diff --git a/packages/app/src/components/directory-picker-domain.ts b/packages/app/src/components/directory-picker-domain.ts new file mode 100644 index 0000000000..9539ae1d01 --- /dev/null +++ b/packages/app/src/components/directory-picker-domain.ts @@ -0,0 +1,407 @@ +export function treeEntries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) { + const prefix = parent.replace(/^\/+|\/+$/g, "") + return nodes.map((node) => { + const path = prefix ? `${prefix}/${node.name}` : node.name + return node.type === "directory" ? path + "/" : path + }) +} + +export function pickerTreeEntries( + parent: string, + nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>, + mode: "directory" | "file", +) { + return treeEntries(parent, mode === "directory" ? nodes.filter((node) => node.type === "directory") : nodes) +} + +export function pickerSearchEntries( + nodes: readonly T[], + mode: "directory" | "file", +) { + return mode === "directory" ? nodes.filter((node) => node.type === "directory") : [...nodes] +} + +export function pickerMode(mode: "directory" | "file", base?: string) { + if (mode === "file") { + return { + includeFiles: true, + action: "file" as const, + entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) { + return treeEntries(parent, nodes) + }, + navigation(path: string) { + return treePathWithin(base, path) ? path : undefined + }, + result(root: string, selected: string) { + return selected || undefined + }, + selection(root: string, path: string) { + if (!treePathWithin(base, root)) return + return selectedTreePath(root, path, "file", base) + }, + } + } + return { + includeFiles: false, + action: "directory" as const, + entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) { + return treeEntries( + parent, + nodes.filter((node) => node.type === "directory"), + ) + }, + navigation(path: string) { + return path + }, + result(root: string, selected: string, valid = true) { + if (!valid) return + return selected || (root ? nativePickerPath(root) : undefined) + }, + selection(root: string, path: string) { + return selectedTreePath(root, path, "directory") + }, + } +} + +export function pickerFileSearchQuery(root: string, input: string, home: string) { + const value = input + .replace(/\\/g, "/") + .replace(/^~(?=\/|$)/, home) + .replace(/\/+$/, "") + const base = root.replace(/\\/g, "/").replace(/\/+$/, "") + if (value === base) return "" + if (value.startsWith(base + "/")) return value.slice(base.length + 1) + return value +} + +export function pickerAbsoluteInput(input: string, home: string, current: string) { + const value = normalizePickerDrive(input).replace(/^~(?=\/|$)/, normalizePickerDrive(home)) + const absolute = pickerRoot(value) ? value : joinPickerPath(current, value) + return canonicalPickerPath(absolute) +} + +export function treePathWithin(base: string | undefined, path: string) { + return pickerRelativePath(base, path) !== undefined +} + +export function canonicalPickerPath(path: string) { + const value = normalizePickerDrive(path) + const root = pickerRoot(value) + const parts = value.slice(root.length).split("/") + const resolved = parts.reduce((output, part) => { + if (!part || part === ".") return output + if (part === "..") { + output.pop() + return output + } + output.push(part) + return output + }, []) + return joinPickerPath(root, resolved.join("/")) +} + +export function pickerRelativePath(base: string | undefined, path: string) { + if (!base) return + const rootPath = canonicalPickerPath(base) + const targetPath = canonicalPickerPath(path) + const insensitive = /^[A-Za-z]:\//.test(rootPath) || rootPath.startsWith("//") + const root = insensitive ? rootPath.toLowerCase() : rootPath + const target = insensitive ? targetPath.toLowerCase() : targetPath + if (target === root) return "" + const prefix = root.endsWith("/") ? root : root + "/" + if (!target.startsWith(prefix)) return + return targetPath.slice(prefix.length) +} + +export function currentPickerSuggestions(result: { query: string; items: readonly T[] } | undefined, query: string) { + if (result?.query !== query) return [] + return result.items +} + +export function preloadTreeDirectories( + parent: string, + nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>, +) { + return treeEntries( + parent, + nodes.filter((node) => node.type === "directory"), + ) +} + +export function advanceTreePreload(advanced: Set, path: string) { + if (advanced.has(path)) return false + advanced.add(path) + return true +} + +export function activeTreeNavigation(request: number, current: number) { + return request === current +} + +export function createPriorityTaskQueue(concurrency: number) { + type Job = { + key: string + priority: "user" | "background" + promise: Promise + run: () => void + } + + const jobs = new Map() + const user: Job[] = [] + const background: Job[] = [] + let active = 0 + + const drain = () => { + while (active < concurrency) { + const job = user.pop() ?? background.shift() + if (!job) return + active++ + job.run() + } + } + + const schedule = (key: string, priority: Job["priority"], task: () => Promise) => { + const existing = jobs.get(key) + if (existing) { + if (priority === "user") promote(key) + return existing.promise + } + + const deferred = Promise.withResolvers() + const job: Job = { + key, + priority, + promise: deferred.promise, + run: () => { + const complete = () => { + active-- + jobs.delete(key) + drain() + } + Promise.resolve() + .then(task) + .then( + (value) => { + complete() + deferred.resolve(value) + }, + (error) => { + complete() + deferred.reject(error) + }, + ) + }, + } + jobs.set(key, job) + ;(priority === "user" ? user : background).push(job) + drain() + return job.promise + } + + const promote = (key: string) => { + const job = jobs.get(key) + if (!job || job.priority === "user") return + const index = background.indexOf(job) + if (index === -1) return + background.splice(index, 1) + job.priority = "user" + user.push(job) + } + + return { schedule, promote } +} + +export function nextTreeScrollTop(current: number, delta: number, scrollHeight: number, clientHeight: number) { + return Math.min(Math.max(0, scrollHeight - clientHeight), Math.max(0, current + delta)) +} + +export function nextSuggestionIndex(current: number, delta: -1 | 1, count: number) { + if (count === 0) return -1 + return (current + delta + count) % count +} + +export function absoluteTreePath(root: string, path: string) { + const base = trimPickerPath(root) + const relative = path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "") + if (!relative) return base || "/" + if (!base || base === "/") return "/" + relative + if (base.endsWith("/")) return base + relative + return `${base}/${relative}` +} + +export function selectedTreePath(root: string, path: string, mode: "directory" | "file", base?: string) { + const directory = path.endsWith("/") + if (mode === "file") { + if (directory) return + if (!base) return path + const absolute = absoluteTreePath(root, path) + return pickerRelativePath(base, absolute) + } + return directory ? nativePickerPath(absoluteTreePath(root, path)) : undefined +} + +export function nativePickerPath(path: string) { + const value = trimPickerPath(path) + if (/^[A-Za-z]:\//.test(value) || value.startsWith("//")) return value.replaceAll("/", "\\") + return value +} +import { getFilename } from "@opencode-ai/core/util/path" +import fuzzysort from "fuzzysort" +import { ServerSDK } from "@/context/server-sdk" + +export function cleanPickerInput(value: string) { + const first = (value ?? "").split(/\r?\n/)[0] ?? "" + return first.replace(/[\u0000-\u001F\u007F]/g, "").trim() +} + +export function normalizePickerPath(input: string) { + const value = input.replaceAll("\\", "/") + if (value.startsWith("//") && !value.startsWith("///")) return "//" + value.slice(2).replace(/\/+/g, "/") + return value.replace(/\/+/g, "/") +} + +export function normalizePickerDrive(input: string) { + const value = normalizePickerPath(input) + if (/^[A-Za-z]:$/.test(value)) return value + "/" + return value +} + +export function trimPickerPath(input: string) { + const value = normalizePickerDrive(input) + if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value + return value.replace(/\/+$/, "") +} + +export function joinPickerPath(base: string | undefined, relative: string) { + const root = trimPickerPath(base ?? "") + const path = trimPickerPath(relative).replace(/^\/+/, "") + if (!root) return path + if (!path) return root + if (root.endsWith("/")) return root + path + return root + "/" + path +} + +export function pickerRoot(input: string) { + const value = normalizePickerDrive(input) + if (value.startsWith("//")) { + const [server, share] = value.slice(2).split("/") + if (server && share) return `//${server}/${share}` + return "//" + } + if (value.startsWith("/")) return "/" + if (/^[A-Za-z]:\//.test(value)) return value.slice(0, 3) + return "" +} + +export function pickerParent(input: string) { + const value = trimPickerPath(input) + const root = pickerRoot(value) + if (value === root) return value + if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value + const index = value.lastIndexOf("/") + if (index < root.length) return root + if (index <= 0) return "/" + if (index === 2 && /^[A-Za-z]:/.test(value)) return value.slice(0, 3) + return value.slice(0, index) +} + +function pickerTilde(absolute: string, home: string) { + const path = trimPickerPath(absolute) + if (!home) return "" + const root = trimPickerPath(home) + if (/^[A-Za-z]:\//.test(root)) return "" + if (path === root) return "~" + if (path.startsWith(root + "/")) return "~" + path.slice(root.length) + return "" +} + +export function displayPickerPath(path: string, input: string, home: string) { + const value = trimPickerPath(path) + if (/^[A-Za-z]:\//.test(trimPickerPath(home)) || /^[A-Za-z]:\//.test(value)) return value.replaceAll("/", "\\") + return pickerTilde(value, home) || value +} + +export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) { + const cache = new Map>>() + let current = 0 + + const scoped = (value: string) => { + const raw = normalizePickerDrive(value) + const root = pickerRoot(raw) + if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) } + const base = args.base() + if (!base) return + if (!raw) return { directory: trimPickerPath(base), path: "" } + const home = args.home() + if (raw === "~") return { directory: trimPickerPath(home || base), path: "" } + if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) } + return { directory: trimPickerPath(base), path: raw } + } + + const directories = async (directory: string) => { + const key = trimPickerPath(directory) + const existing = cache.get(key) + if (existing) return existing + const request = args.sdk.api.file + .list({ location: { directory: key } }) + .then((result) => result.data) + .catch(() => []) + .then((nodes) => + nodes + .filter((node) => node.type === "directory") + .map((node) => { + const relative = trimPickerPath(normalizePickerDrive(node.path)) + return { name: getFilename(relative), absolute: joinPickerPath(key, relative) } + }), + ) + cache.set(key, request) + return request + } + + const match = async (directory: string, query: string, limit: number) => { + const items = await directories(directory) + if (!query) return items.slice(0, limit).map((item) => item.absolute) + return fuzzysort.go(query, items, { key: "name", limit }).map((item) => item.obj.absolute) + } + + return async (filter: string) => { + const token = ++current + const active = () => token === current + const value = cleanPickerInput(filter) + const input = scoped(value) + if (!input) return [] as string[] + const raw = normalizePickerDrive(value) + const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/") + const query = normalizePickerDrive(input.path) + if (!pathInput) { + const results = await args.sdk.api.file + .find({ location: { directory: input.directory }, query, type: "directory", limit: 50 }) + .then((result) => result.data.map((entry) => entry.path)) + .catch(() => []) + if (!active()) return [] + return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50) + } + const segments = query.replace(/^\/+/, "").split("/") + const head = segments.slice(0, -1).filter((part) => part && part !== ".") + const tail = segments.at(-1) ?? "" + let paths = [input.directory] + for (const part of head) { + if (!active()) return [] + if (part === "..") { + paths = paths.map(pickerParent) + continue + } + paths = Array.from(new Set((await Promise.all(paths.map((path) => match(path, part, 4)))).flat())).slice(0, 12) + if (!active() || paths.length === 0) return [] + } + const matches = Array.from(new Set((await Promise.all(paths.map((path) => match(path, tail, 50)))).flat())) + if (!active()) return [] + const base = raw.startsWith("~") ? trimPickerPath(input.directory) : "" + if (raw.endsWith("/") || !tail) return Array.from(new Set([base, ...matches].filter(Boolean))).slice(0, 50) + const target = matches.find((path) => getFilename(path).toLowerCase() === tail.toLowerCase()) + if (!target) return matches.slice(0, 50) + const children = await match(target, "", 30) + if (!active()) return [] + return Array.from(new Set([base, ...matches, ...children].filter(Boolean))).slice(0, 50) + } +} diff --git a/packages/app/src/components/directory-picker.tsx b/packages/app/src/components/directory-picker.tsx index e0ee1cec39..31b15b7a6e 100644 --- a/packages/app/src/components/directory-picker.tsx +++ b/packages/app/src/components/directory-picker.tsx @@ -1,9 +1,15 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { ServerConnection } from "@/context/server" import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" +import { lazy } from "solid-js" import { DialogSelectDirectory } from "./dialog-select-directory" import { directoryPickerKind } from "./directory-picker-policy" +const DialogSelectDirectoryV2 = lazy(() => + import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })), +) + type DirectoryPickerInput = { server: ServerConnection.Any title?: string @@ -13,6 +19,7 @@ type DirectoryPickerInput = { export function useDirectoryPicker() { const platform = usePlatform() + const settings = useSettings() const dialog = useDialog() return (input: DirectoryPickerInput) => { @@ -21,9 +28,18 @@ export function useDirectoryPicker() { return } - dialog.show( - () => , - () => input.onSelect(null), - ) + let selected = false + const onSelect = (result: string | string[] | null) => { + selected = result !== null + input.onSelect(result) + } + const cancel = () => { + if (!selected) input.onSelect(null) + } + if (platform.platform === "desktop" && settings.general.newLayoutDesigns()) { + dialog.show(() => , cancel) + return + } + dialog.show(() => , cancel) } } diff --git a/packages/app/src/components/edit-project.ts b/packages/app/src/components/edit-project.ts new file mode 100644 index 0000000000..0c5d576e93 --- /dev/null +++ b/packages/app/src/components/edit-project.ts @@ -0,0 +1,133 @@ +import { getFilename } from "@opencode-ai/core/util/path" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useMutation } from "@tanstack/solid-query" +import { normalizeProjectInfo } from "@/context/global-sync/utils" +import { createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import { useGlobal } from "@/context/global" +import { type LocalProject } from "@/context/layout" +import { ServerConnection } from "@/context/server" + +export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) { + const dialog = useDialog() + const global = useGlobal() + const serverCtx = createMemo(() => global.ensureServerCtx(props.server)) + 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 selectFile(file: File) { + if (!file.type.startsWith("image/")) return + const reader = new FileReader() + reader.onload = (event) => { + const result = event.target?.result + if (typeof result !== "string") return + setStore("iconOverride", result) + setStore("iconHover", false) + } + reader.readAsDataURL(file) + } + + function drop(event: DragEvent) { + event.preventDefault() + setStore("dragOver", false) + const file = event.dataTransfer?.files[0] + if (file) selectFile(file) + } + + function dragOver(event: DragEvent) { + event.preventDefault() + setStore("dragOver", true) + } + + function dragLeave() { + setStore("dragOver", false) + } + + function inputChange(event: Event) { + const file = (event.currentTarget as HTMLInputElement).files?.[0] + if (file) selectFile(file) + } + + function iconClick() { + if (store.iconOverride && store.iconHover) { + setStore("iconOverride", "") + return + } + iconInput?.click() + } + + const save = 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") { + if ((await serverCtx().sdk.protocol) !== "v1") return + const project = await serverCtx() + .sdk.client.project.update({ + projectID: props.project.id, + directory: props.project.worktree, + name, + icon: { color: store.color || "", override: store.iconOverride || "" }, + commands: { start }, + }) + .then((result) => result.data) + if (!project) return + // const project = await serverCtx().sdk.api.project.update({ + // projectID: props.project.id, + // name, + // icon: { color: store.color || "", override: store.iconOverride || "" }, + // commands: { start }, + // }) + serverCtx().sync.set("project", (items) => + items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)), + ) + serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined) + dialog.close() + return + } + + serverCtx().sync.project.meta(props.project.worktree, { + name, + icon: { color: store.color || undefined, override: store.iconOverride || undefined }, + commands: { start: start || undefined }, + }) + dialog.close() + }, + })) + + function submit(event: SubmitEvent) { + event.preventDefault() + if (save.isPending) return + save.mutate() + } + + return { + store, + setStore, + folderName, + defaultName, + save, + submit, + drop, + dragOver, + dragLeave, + inputChange, + iconClick, + close() { + dialog.close() + }, + setIconInput(input: HTMLInputElement) { + iconInput = input + }, + } +} diff --git a/packages/app/src/components/external-link.tsx b/packages/app/src/components/external-link.tsx new file mode 100644 index 0000000000..133e752eab --- /dev/null +++ b/packages/app/src/components/external-link.tsx @@ -0,0 +1,21 @@ +import { ComponentProps, splitProps } from "solid-js" + +export interface ExternalLinkProps extends Omit, "href"> { + href: string +} + +export function ExternalLink(props: ExternalLinkProps) { + const [local, rest] = splitProps(props, ["href", "children", "class", "target", "rel"]) + + return ( + + {local.children} + + ) +} diff --git a/packages/app/src/components/file-tree-v2-model.test.ts b/packages/app/src/components/file-tree-v2-model.test.ts new file mode 100644 index 0000000000..f2a63fe86d --- /dev/null +++ b/packages/app/src/components/file-tree-v2-model.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test" +import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model" +import type { FileNode } from "@opencode-ai/sdk/v2" + +describe("buildFileTreeV2Model", () => { + test("builds a sorted tree and flattens expanded directories", () => { + const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"]) + + expect(model.total).toBe(8) + expect(flattenFileTreeV2(model, () => true).map((row) => [row.node.path, row.node.type, row.level])).toEqual([ + ["docs", "directory", 0], + ["docs/guide.md", "file", 1], + ["src", "directory", 0], + ["src/lib", "directory", 1], + ["src/lib/a.ts", "file", 2], + ["src/lib/b.ts", "file", 2], + ["src/z.ts", "file", 1], + ["README.md", "file", 0], + ]) + }) + + test("skips children of collapsed directories", () => { + const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"]) + + expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([ + "src", + "src/lib", + "src/z.ts", + ]) + }) + + test("normalizes duplicate and messy paths", () => { + const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"]) + const rows = flattenFileTreeV2(model, () => true) + + expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"]) + expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts") + }) + + test("handles deeply nested paths", () => { + const file = Array.from({ length: 130 }, (_, index) => `d${index}`).join("/") + "/leaf.ts" + const model = buildFileTreeV2Model([file]) + + expect(flattenFileTreeV2(model, () => true)).toHaveLength(131) + }) +}) + +describe("flattenLiveFileTreeV2", () => { + test("flattens live children using original paths for nested lookups", () => { + const nodes: Record = { + "": [ + { name: "src", path: "src", absolute: "/repo/src", type: "directory", ignored: false }, + { name: "README.md", path: "README.md", absolute: "/repo/README.md", type: "file", ignored: false }, + ], + src: [ + { name: "a.ts", path: "src/a.ts", absolute: "/repo/src/a.ts", type: "file", ignored: false }, + { name: "lib", path: "src/lib", absolute: "/repo/src/lib", type: "directory", ignored: false }, + ], + "src/lib": [{ name: "b.ts", path: "src/lib/b.ts", absolute: "/repo/src/lib/b.ts", type: "file", ignored: false }], + } + + expect( + flattenLiveFileTreeV2( + (path) => nodes[path] ?? [], + (path) => path === "src", + ).map((row) => [row.node.path, row.node.originalPath, row.level]), + ).toEqual([ + ["src", "src", 0], + ["src/a.ts", "src/a.ts", 1], + ["src/lib", "src/lib", 1], + ["README.md", "README.md", 0], + ]) + }) +}) diff --git a/packages/app/src/components/file-tree-v2-model.ts b/packages/app/src/components/file-tree-v2-model.ts new file mode 100644 index 0000000000..27783a9bb1 --- /dev/null +++ b/packages/app/src/components/file-tree-v2-model.ts @@ -0,0 +1,107 @@ +import type { FileNode } from "@opencode-ai/sdk/v2" + +export type FileTreeV2Model = { + children: ReadonlyMap + total: number +} + +export type FileTreeV2Node = FileNode & { originalPath: string } + +export type FileTreeV2Row = { + node: FileTreeV2Node + level: number +} + +export function normalizeFileTreeV2Path(value: string) { + return value + .replaceAll("\\", "/") + .replace(/^\/+|\/+$/g, "") + .replace(/\/{2,}/g, "/") +} + +export function buildFileTreeV2Model(paths: readonly string[]): FileTreeV2Model { + const nodes = new Map() + + paths.forEach((value) => { + const file = normalizeFileTreeV2Path(value) + if (!file) return + + const parts = file.split("/") + parts.forEach((name, index) => { + const path = parts.slice(0, index + 1).join("/") + if (nodes.has(path)) return + nodes.set(path, { + name, + path, + absolute: path, + type: index === parts.length - 1 ? "file" : "directory", + ignored: false, + originalPath: index === parts.length - 1 ? value : path, + }) + }) + }) + + const children = new Map() + nodes.forEach((node) => { + const index = node.path.lastIndexOf("/") + const parent = index === -1 ? "" : node.path.slice(0, index) + const list = children.get(parent) + if (list) list.push(node) + else children.set(parent, [node]) + }) + children.forEach((nodes) => + nodes.sort((a, b) => { + if (a.type !== b.type) return a.type === "directory" ? -1 : 1 + return a.name.localeCompare(b.name) + }), + ) + + return { children, total: nodes.size } +} + +export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: string) => boolean) { + const rows: FileTreeV2Row[] = [] + const stack = (model.children.get("") ?? []).toReversed().map((node) => ({ node, level: 0 })) + + while (stack.length > 0) { + const row = stack.pop()! + rows.push(row) + if (row.node.type !== "directory" || !expanded(row.node.path)) continue + const children = model.children.get(row.node.path) ?? [] + for (let index = children.length - 1; index >= 0; index--) { + stack.push({ node: children[index]!, level: row.level + 1 }) + } + } + + return rows +} + +export function flattenLiveFileTreeV2( + children: (path: string) => readonly FileNode[], + expanded: (path: string) => boolean, +) { + const rows: FileTreeV2Row[] = [] + const stack = children("") + .toReversed() + .map((node) => ({ node: toLiveNode(node), level: 0 })) + + while (stack.length > 0) { + const row = stack.pop()! + rows.push(row) + if (row.node.type !== "directory" || !expanded(row.node.path)) continue + const nested = children(row.node.originalPath) + for (let index = nested.length - 1; index >= 0; index--) { + stack.push({ node: toLiveNode(nested[index]!), level: row.level + 1 }) + } + } + + return rows +} + +function toLiveNode(node: FileNode): FileTreeV2Node { + return { + ...node, + path: normalizeFileTreeV2Path(node.path), + originalPath: node.path, + } +} diff --git a/packages/app/src/components/file-tree-v2.tsx b/packages/app/src/components/file-tree-v2.tsx new file mode 100644 index 0000000000..26cf49d411 --- /dev/null +++ b/packages/app/src/components/file-tree-v2.tsx @@ -0,0 +1,299 @@ +import { useFile } from "@/context/file" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import "@opencode-ai/ui/v2/file-tree-v2.css" +import { + createEffect, + createMemo, + createSignal, + For, + Show, + splitProps, + type ComponentProps, + type ParentProps, +} from "solid-js" +import { Dynamic } from "solid-js/web" +import type { FileNode } from "@opencode-ai/sdk/v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree" +import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" +import { + buildFileTreeV2Model, + flattenFileTreeV2, + flattenLiveFileTreeV2, + normalizeFileTreeV2Path, + type FileTreeV2Node, +} from "@/components/file-tree-v2-model" +import { virtualScrollElement } from "@/components/virtual-scroll-element" + +export type { Kind } from "@/components/file-tree" + +const INDENT_STEP = 16 + +function rowPaddingLeft(level: number, type: FileNode["type"]) { + if (type === "directory") return 8 + level * INDENT_STEP + if (level === 0) return 8 + return 8 + level * INDENT_STEP - INDENT_STEP +} + +function guideLineLeft(level: number) { + return rowPaddingLeft(level, "directory") + 8 +} + +export const kindLabel = (kind: Kind) => { + if (kind === "add") return "A" + if (kind === "del") return "D" + return "M" +} + +export const kindChange = (kind: Kind) => { + if (kind === "add") return "added" + if (kind === "del") return "deleted" + return "modified" +} + +const FileTreeNodeV2 = ( + p: ParentProps & + ComponentProps<"div"> & + ComponentProps<"button"> & { + node: FileNode + level: number + active?: string + draggable: boolean + kinds?: ReadonlyMap + as?: "div" | "button" + }, +) => { + const [local, rest] = splitProps(p, [ + "node", + "level", + "active", + "draggable", + "kinds", + "as", + "children", + "class", + "classList", + ]) + const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path)) + + return ( + { + if (!local.draggable) return + event.dataTransfer?.setData("text/plain", `file:${local.node.path}`) + event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path)) + if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy" + withFileDragImage(event) + }} + {...rest} + > + {local.children} + {local.node.name} + {(() => { + const value = kind() + if (!value || local.node.type !== "file") return null + return ( + + {kindLabel(value)} + + ) + })()} + + ) +} + +function GuideLines(props: { level: number }) { + return ( + + {(_, index) =>
} + + ) +} + +export default function FileTreeV2(props: { + active?: string + allowed?: readonly string[] + kinds?: ReadonlyMap + draggable?: boolean + onFileClick?: (file: FileNode) => void + onFileDoubleClick?: (file: FileNode) => void +}) { + const file = useFile() + const live = () => props.allowed === undefined + const draggable = () => props.draggable ?? true + const active = () => normalizeFileTreeV2Path(props.active ?? "") + const model = createMemo(() => (live() ? undefined : buildFileTreeV2Model(props.allowed ?? []))) + const expanded = (path: string) => file.tree.state(path)?.expanded ?? !live() + const rows = createMemo(() => { + if (live()) return flattenLiveFileTreeV2((path) => file.tree.children(path), expanded) + return flattenFileTreeV2(model()!, expanded) + }) + const [root, setRoot] = createSignal() + const [focused, setFocused] = createSignal() + const virtualizer = createVirtualizer({ + get count() { + return rows().length + }, + getScrollElement: () => virtualScrollElement(root()), + initialRect: { width: 0, height: 600 }, + estimateSize: () => 28, + gap: 2, + overscan: 10, + get getItemKey() { + const current = rows() + return (index: number) => current[index]?.node.path ?? index + }, + rangeExtractor: (range) => { + const indexes = defaultRangeExtractor(range) + const path = focused() + const index = path ? rows().findIndex((row) => row.node.path === path) : -1 + if (index < 0 || indexes.includes(index)) return indexes + return [...indexes, index].sort((a, b) => a - b) + }, + }) + + createEffect(() => { + if (!live()) return + void file.tree.list("") + }) + + // Only scroll when the active path changes (or first appears in the tree). + // Do not re-scroll when expand/collapse reshuffles `rows()`. + let scrolledActive: string | undefined + createEffect(() => { + const path = active() + if (!path) { + scrolledActive = undefined + return + } + const index = rows().findIndex((row) => row.node.path === path) + if (index < 0) return + if (scrolledActive === path) return + scrolledActive = path + queueMicrotask(() => { + const next = rows().findIndex((row) => row.node.path === path) + if (next < 0) return + if (virtualizer.range && next >= virtualizer.range.startIndex && next <= virtualizer.range.endIndex) return + virtualizer.scrollToIndex(next, { align: "auto" }) + }) + }) + + const selectFile = (node: FileTreeV2Node, action?: (file: FileNode) => void) => { + action?.({ + ...node, + path: node.originalPath, + absolute: node.originalPath, + }) + } + + const toggleDirectory = (path: string, originalPath: string) => { + if (expanded(path)) { + file.tree.collapse(originalPath) + return + } + file.tree.expand(originalPath, live() ? undefined : { list: false }) + } + + const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const))) + const virtualItemByKey = createMemo( + () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)), + ) + const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key)) + + return ( +
+ + {(key) => ( + + {(item) => ( +
+ + {(row) => ( + setFocused(row().node.path)} + onBlur={() => setFocused(undefined)} + onClick={() => selectFile(row().node, props.onFileClick)} + onDblClick={() => selectFile(row().node, props.onFileDoubleClick)} + > + + 0}> +
+ + + + + + + } + > + setFocused(row().node.path)} + onBlur={() => setFocused(undefined)} + aria-expanded={expanded(row().node.path)} + onClick={() => toggleDirectory(row().node.path, row().node.originalPath)} + > + +
+ +
+
+ + )} + +
+ )} +
+ )} + +
+ ) +} diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 211ce05ef0..718e2f3493 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -21,13 +21,13 @@ import type { FileNode } from "@opencode-ai/sdk/v2" const MAX_DEPTH = 128 -function pathToFileUrl(filepath: string): string { +export function pathToFileUrl(filepath: string): string { return `file://${encodeFilePath(filepath)}` } -type Kind = "add" | "del" | "mix" +export type Kind = "add" | "del" | "mix" -type Filter = { +export type Filter = { files: Set dirs: Set } @@ -78,7 +78,7 @@ const kindDotColor = (kind: Kind) => { return "background-color: var(--icon-diff-modified-base)" } -const visibleKind = (node: FileNode, kinds?: ReadonlyMap, marks?: Set) => { +export const visibleKind = (node: FileNode, kinds?: ReadonlyMap, marks?: Set) => { const kind = kinds?.get(node.path) if (!kind) return if (!marks?.has(node.path)) return @@ -99,7 +99,7 @@ const buildDragImage = (target: HTMLElement) => { return image } -const withFileDragImage = (event: DragEvent) => { +export const withFileDragImage = (event: DragEvent) => { const image = buildDragImage(event.currentTarget as HTMLElement) if (!image) return document.body.appendChild(image) @@ -201,6 +201,7 @@ export default function FileTree(props: { kinds?: ReadonlyMap draggable?: boolean onFileClick?: (file: FileNode) => void + onFileDoubleClick?: (file: FileNode) => void _filter?: Filter _marks?: Set @@ -440,6 +441,7 @@ export default function FileTree(props: { active={props.active} draggable={props.draggable} onFileClick={props.onFileClick} + onFileDoubleClick={props.onFileDoubleClick} _filter={filter()} _marks={marks()} _deeps={deeps()} @@ -462,6 +464,7 @@ export default function FileTree(props: { as="button" type="button" onClick={() => props.onFileClick?.(node)} + onDblClick={() => props.onFileDoubleClick?.(node)} >
diff --git a/packages/app/src/components/help-button.tsx b/packages/app/src/components/help-button.tsx index 73d623fc6a..18dd727084 100644 --- a/packages/app/src/components/help-button.tsx +++ b/packages/app/src/components/help-button.tsx @@ -1,54 +1,148 @@ -import { Icon } from "@opencode-ai/ui/v2/icon" -import { Popover } from "@opencode-ai/ui/popover" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { createSignal, Show } from "solid-js" -import { createStore } from "solid-js/store" +import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer" +import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" +import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4" +import homeImage from "@/assets/help/home.png" +import tabsImage from "@/assets/help/tabs.png" -export function HelpButton() { - if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null +// TODO: wire to changelog / seen-state when available +const showPopover = () => true - const [state, setState] = /* persisted(Persist.global("help-button"), */ createStore({ dismissed: false }) /* ) */ - const [shown, setShown] = createSignal(false) +// can remove this after the tabs rollout has been out for a while +export function TabsInfoPopup() { + const settings = useSettings() + const platform = usePlatform() + const [drawerOpen, setDrawerOpen] = createSignal(false) + const windows = () => platform.platform === "desktop" && platform.os === "windows" return ( - -
-