diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 9501a1be65..52eec90991 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 support, troubleshooting, how-to questions, and real-time discussion. + about: For quick questions or real-time discussion. Note that issues are searchable and help others with the same question. diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000000..8930ba693c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,10 @@ +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 ee2e26f452..f892eb95db 100644 --- a/.github/TEAM_MEMBERS +++ b/.github/TEAM_MEMBERS @@ -1,5 +1,4 @@ adamdotdevin -arvsrn Brendonovich fwang Hona @@ -8,14 +7,11 @@ 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 9d29724d86..5b44517ec5 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -8,13 +8,6 @@ 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 a83824e5cf..14e68701e5 100644 --- a/.github/workflows/compliance-close.yml +++ b/.github/workflows/compliance-close.yml @@ -34,48 +34,10 @@ 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 3972247daf..4648a2d0c3 100644 --- a/.github/workflows/duplicate-issues.yml +++ b/.github/workflows/duplicate-issues.yml @@ -17,31 +17,12 @@ 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 }} @@ -57,7 +38,6 @@ 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 }}. @@ -69,8 +49,6 @@ 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 @@ -105,7 +83,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 and the author association is not OWNER or MEMBER, start the comment with: + If the issue is NOT compliant, 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 @@ -151,31 +129,12 @@ 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 }} @@ -189,12 +148,9 @@ 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 1c41a66faa..29b5aa3047 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -219,6 +219,7 @@ jobs: build-electron: needs: + - build-cli - version if: github.repository == 'anomalyco/opencode' continue-on-error: false @@ -315,13 +316,15 @@ 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 }} @@ -339,7 +342,8 @@ jobs: env: OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} GH_TOKEN: ${{ steps.committer.outputs.token }} - CSC_KEYCHAIN: build.keychain + CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }} + CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8 APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index be2e099d0e..1e652104d6 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -9,7 +9,6 @@ on: - "bun.lock" - "packages/storybook/**" - "packages/ui/**" - - "packages/session-ui/**" pull_request: branches: [dev] paths: @@ -18,7 +17,6 @@ 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 c69de1d93b..aeeccc42fe 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,15 +65,10 @@ jobs: - name: Run unit tests timeout-minutes: 20 - run: GITHUB_ACTIONS=false bun turbo test + run: 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 0350e43877..27852a12ce 100644 --- a/.github/workflows/triage.yml +++ b/.github/workflows/triage.yml @@ -16,32 +16,13 @@ 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 de18ae2ee8..8d493f4a81 100644 --- a/.opencode/command/translate.md +++ b/.opencode/command/translate.md @@ -1,6 +1,6 @@ --- description: translate English to other languages -model: opencode/gpt-5.6-sol +model: opencode/claude-opus-4-8 --- 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/tool/github-triage.ts b/.opencode/tool/github-triage.ts index e861e1e467..f25ca48b38 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"], + core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton", "starptech"], inference: ["fwang", "MrMushrooooom", "starptech"], windows: ["Hona"], } as const diff --git a/.prettierignore b/.prettierignore index 7c72d70225..a2a2776596 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,2 @@ 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 cd2327e888..02b1c4cb77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,4 @@ -- 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. +- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`. - The default branch in this repo is `dev`. - Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. @@ -30,7 +28,6 @@ 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. @@ -140,7 +137,7 @@ const table = sqliteTable("session", { ## Testing -- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option. +- Avoid mocks as much as possible - 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`. @@ -155,7 +152,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 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 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 EventV2 replay owner claims separate from clustered Session execution ownership. - Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned. diff --git a/CONTEXT.md b/CONTEXT.md index 5e5955d344..1c12ba641c 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 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. +The span during which one effective agent's initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition. **Baseline System Context**: The full **System Context** rendered at the start of a **Context Epoch**. @@ -39,18 +39,6 @@ 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. @@ -64,27 +52,9 @@ _Avoid_: Request body, wire options **Generation Controls**: Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog. -**Native Continuation Metadata**: -Opaque protocol-shaped data attached to assistant content and required to continue that content natively with a compatible model, such as a reasoning signature or provider-hosted item identifier. - **PTY Environment**: The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory. -**OpenCode Client**: -The generated Promise and Effect APIs derived from the public `HttpApi`; **Embedded OpenCode** shares the Effect API through an in-memory `HttpClient` against the same router and handlers. -_Avoid_: Remote client - -**SDK Contract IR**: -The runtime-neutral compiled representation of the authoritative `HttpApi`, preserving encoded and decoded type projections plus transport metadata so independent SDK emitters can choose their public value model and runtime interpreter. - -**Embedded OpenCode**: -A scoped in-process host that structurally extends the **OpenCode Client**, supplies an in-memory HTTP transport, and exposes additional same-process capabilities directly. -_Avoid_: Local implementation - -**Page**: -A bounded ordered result containing `items` and opaque `previous` and `next` cursor links for navigating the same query in either direction. -_Avoid_: Response envelope - ## Relationships - A **System Context** is an opaque carrier composed from zero or more **Context Sources**. @@ -97,11 +67,6 @@ _Avoid_: Response envelope - 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. @@ -110,78 +75,34 @@ _Avoid_: Response envelope - 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(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable. +- `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. - **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. -- 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. +- 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. - 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. -- 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. +- 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. - **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. @@ -198,23 +119,6 @@ _Avoid_: Response envelope - **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 352e13ccaa..93f81e83cd 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=52ao9352-5623-4fa0-b7dd-3407c392c1af&qr_code=true) | [X.com](https://x.com/opencode) +**ๅŠ ๅ…ฅๆˆ‘ไปฌ็š„็คพๅŒบ** [้ฃžไนฆ](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) diff --git a/README.zht.md b/README.zht.md index f09358b133..59d9709bd3 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=52ao9352-5623-4fa0-b7dd-3407c392c1af&qr_code=true) | [X.com](https://x.com/opencode) +**ๅŠ ๅ…ฅๆˆ‘ๅ€‘็š„็คพ็พค** [้ฃžไนฆ](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) diff --git a/artifacts/glm52-rise-video/.gitignore b/artifacts/glm52-rise-video/.gitignore deleted file mode 100644 index 9d38bb2591..0000000000 --- a/artifacts/glm52-rise-video/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules -.remotion -out/frame-*.png diff --git a/artifacts/glm52-rise-video/bun.lock b/artifacts/glm52-rise-video/bun.lock deleted file mode 100644 index cac009ae81..0000000000 --- a/artifacts/glm52-rise-video/bun.lock +++ /dev/null @@ -1,483 +0,0 @@ -{ - "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 deleted file mode 100644 index 3e182b4c11..0000000000 Binary files a/artifacts/glm52-rise-video/out/flash-share.mp4 and /dev/null 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 deleted file mode 100644 index eb456d0345..0000000000 Binary files a/artifacts/glm52-rise-video/out/glm-52-broke-out.mp4 and /dev/null differ diff --git a/artifacts/glm52-rise-video/out/june-totals.png b/artifacts/glm52-rise-video/out/june-totals.png deleted file mode 100644 index 678376dbed..0000000000 Binary files a/artifacts/glm52-rise-video/out/june-totals.png and /dev/null differ diff --git a/artifacts/glm52-rise-video/out/minimax-climb.mp4 b/artifacts/glm52-rise-video/out/minimax-climb.mp4 deleted file mode 100644 index 8ca6b678ae..0000000000 Binary files a/artifacts/glm52-rise-video/out/minimax-climb.mp4 and /dev/null differ diff --git a/artifacts/glm52-rise-video/out/novel-1984.mp4 b/artifacts/glm52-rise-video/out/novel-1984.mp4 deleted file mode 100644 index b94a899257..0000000000 Binary files a/artifacts/glm52-rise-video/out/novel-1984.mp4 and /dev/null differ diff --git a/artifacts/glm52-rise-video/out/nz-sheep.mp4 b/artifacts/glm52-rise-video/out/nz-sheep.mp4 deleted file mode 100644 index 64d382da26..0000000000 Binary files a/artifacts/glm52-rise-video/out/nz-sheep.mp4 and /dev/null differ diff --git a/artifacts/glm52-rise-video/package.json b/artifacts/glm52-rise-video/package.json deleted file mode 100644 index fb08574eb2..0000000000 --- a/artifacts/glm52-rise-video/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "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 deleted file mode 100644 index 662ca244cd..0000000000 Binary files a/artifacts/glm52-rise-video/public/book.jpg and /dev/null differ diff --git a/artifacts/glm52-rise-video/public/sheep.jpg b/artifacts/glm52-rise-video/public/sheep.jpg deleted file mode 100644 index d9ed232a89..0000000000 Binary files a/artifacts/glm52-rise-video/public/sheep.jpg and /dev/null differ diff --git a/artifacts/glm52-rise-video/src/data.ts b/artifacts/glm52-rise-video/src/data.ts deleted file mode 100644 index d9fab2a631..0000000000 --- a/artifacts/glm52-rise-video/src/data.ts +++ /dev/null @@ -1,156 +0,0 @@ -// 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 deleted file mode 100644 index 5d7aaef9bf..0000000000 --- a/artifacts/glm52-rise-video/src/flash.tsx +++ /dev/null @@ -1,185 +0,0 @@ -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 deleted file mode 100644 index 3265151ac5..0000000000 --- a/artifacts/glm52-rise-video/src/index.tsx +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index aa3f9de9be..0000000000 --- a/artifacts/glm52-rise-video/src/june.tsx +++ /dev/null @@ -1,144 +0,0 @@ -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 deleted file mode 100644 index 44cf09a1e7..0000000000 --- a/artifacts/glm52-rise-video/src/minimax.tsx +++ /dev/null @@ -1,201 +0,0 @@ -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 deleted file mode 100644 index 9d649de4bb..0000000000 --- a/artifacts/glm52-rise-video/src/novel.tsx +++ /dev/null @@ -1,135 +0,0 @@ -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 deleted file mode 100644 index 53abb39ee5..0000000000 --- a/artifacts/glm52-rise-video/src/sheep.tsx +++ /dev/null @@ -1,139 +0,0 @@ -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 deleted file mode 100644 index 41466a7e6c..0000000000 --- a/artifacts/glm52-rise-video/src/video.tsx +++ /dev/null @@ -1,254 +0,0 @@ -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 deleted file mode 100644 index 64441936d7..0000000000 --- a/artifacts/glm52-rise-video/sst-env.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* 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 e905ada8c5..657424dfef 100644 --- a/bun.lock +++ b/bun.lock @@ -24,24 +24,16 @@ "prettier": "3.6.2", "semver": "^7.6.0", "sst": "catalog:", - "turbo": "2.10.2", + "turbo": "2.8.13", }, }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.18.11", + "version": "1.17.9", "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:", @@ -66,7 +58,7 @@ "diff": "catalog:", "effect": "catalog:", "fuzzysort": "catalog:", - "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", + "ghostty-web": "github:anomalyco/ghostty-web#main", "luxon": "catalog:", "marked": "catalog:", "marked-shiki": "catalog:", @@ -74,7 +66,6 @@ "shiki": "catalog:", "solid-js": "catalog:", "solid-list": "catalog:", - "solid-presence": "0.2.0", "tailwindcss": "catalog:", }, "devDependencies": { @@ -87,7 +78,6 @@ "@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", @@ -96,7 +86,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.18.11", + "version": "1.17.9", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -119,46 +109,9 @@ "@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.11", - "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.18.11", + "version": "1.17.9", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -194,7 +147,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.18.11", + "version": "1.17.9", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -221,7 +174,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.18.11", + "version": "1.17.9", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -243,7 +196,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.18.11", + "version": "1.17.9", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -267,7 +220,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.18.11", + "version": "1.17.9", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -287,7 +240,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.18.11", + "version": "1.17.9", "bin": { "opencode": "./bin/opencode", }, @@ -295,7 +248,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.88", + "@ai-sdk/azure": "3.0.49", "@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", @@ -303,15 +256,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.51", - "@ai-sdk/openai": "3.0.84", + "@ai-sdk/mistral": "3.0.27", + "@ai-sdk/openai": "3.0.53", "@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.102", + "@ai-sdk/xai": "3.0.82", "@aws-sdk/credential-providers": "3.1057.0", "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", @@ -324,7 +277,6 @@ "@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", @@ -335,11 +287,10 @@ "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.12.1", + "gitlab-ai-provider": "6.9.3", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -352,7 +303,7 @@ "npm-package-arg": "13.0.2", "semver": "^7.6.3", "turndown": "7.2.0", - "venice-ai-sdk-provider": "2.1.1", + "venice-ai-sdk-provider": "2.0.2", "which": "6.0.1", "xdg-basedir": "5.1.0", "zod": "catalog:", @@ -381,16 +332,16 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.18.11", + "version": "1.17.9", "dependencies": { "@zip.js/zip.js": "2.7.62", - "drizzle-orm": "catalog:", "effect": "catalog:", "electron-context-menu": "4.1.2", "electron-log": "^5", "electron-store": "11.0.2", "electron-updater": "6.8.9", "electron-window-state": "^5.0.3", + "marked": "^15", }, "devDependencies": { "@actions/artifact": "4.0.0", @@ -435,7 +386,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.18.11", + "version": "1.17.9", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -449,7 +400,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.18.11", + "version": "1.17.9", "dependencies": { "effect": "catalog:", }, @@ -461,11 +412,10 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.18.11", + "version": "1.17.9", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", - "@opencode-ai/session-ui": "workspace:*", "@opencode-ai/ui": "workspace:*", "@pierre/diffs": "catalog:", "@solidjs/meta": "catalog:", @@ -493,7 +443,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.18.11", + "version": "1.17.9", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -509,7 +459,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.18.11", + "version": "1.17.9", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -526,23 +476,10 @@ "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.18.11", + "version": "1.17.9", "dependencies": { - "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", @@ -559,7 +496,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.18.11", + "version": "1.17.9", "bin": { "opencode": "./bin/opencode", }, @@ -570,22 +507,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.88", - "@ai-sdk/cerebras": "2.0.60", + "@ai-sdk/azure": "3.0.49", + "@ai-sdk/cerebras": "2.0.41", "@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.51", - "@ai-sdk/openai": "3.0.84", + "@ai-sdk/mistral": "3.0.27", + "@ai-sdk/openai": "3.0.53", "@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.102", + "@ai-sdk/xai": "3.0.82", "@aws-sdk/credential-providers": "3.1057.0", "@clack/prompts": "1.0.0-alpha.1", "@effect/opentelemetry": "catalog:", @@ -596,11 +533,8 @@ "@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:*", @@ -632,7 +566,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.12.1", + "gitlab-ai-provider": "6.9.3", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -656,7 +590,7 @@ "tree-sitter-powershell": "0.25.10", "turndown": "7.2.0", "ulid": "catalog:", - "venice-ai-sdk-provider": "2.1.1", + "venice-ai-sdk-provider": "2.0.2", "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", "ws": "8.21.0", @@ -690,7 +624,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.18.11", + "version": "1.17.9", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -707,9 +641,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.4.5", - "@opentui/keymap": ">=0.4.5", - "@opentui/solid": ">=0.4.5", + "@opentui/core": ">=0.3.4", + "@opentui/keymap": ">=0.3.4", + "@opentui/solid": ">=0.3.4", }, "optionalPeers": [ "@opentui/core", @@ -717,29 +651,6 @@ "@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": { @@ -750,23 +661,9 @@ "@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.18.11", + "version": "1.17.9", "dependencies": { "cross-spawn": "catalog:", }, @@ -781,10 +678,9 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.18.11", + "version": "1.17.9", "dependencies": { "@opencode-ai/core": "workspace:*", - "@opencode-ai/protocol": "workspace:*", "drizzle-orm": "catalog:", "effect": "catalog:", }, @@ -794,53 +690,9 @@ "@typescript/native-preview": "catalog:", }, }, - "packages/session-ui": { - "name": "@opencode-ai/session-ui", - "version": "1.18.11", - "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.18.11", + "version": "1.17.9", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -853,10 +705,9 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.18.11", + "version": "1.17.9", "dependencies": { "@ibm/plex": "6.4.1", - "@kobalte/core": "catalog:", "@opencode-ai/stats-core": "workspace:*", "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -887,7 +738,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.18.11", + "version": "1.17.9", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -906,7 +757,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.18.11", + "version": "1.17.9", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -925,7 +776,6 @@ "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", @@ -938,7 +788,6 @@ "@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", @@ -948,7 +797,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.18.11", + "version": "1.17.9", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -975,9 +824,11 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.18.11", + "version": "1.17.9", "dependencies": { "@kobalte/core": "catalog:", + "@opencode-ai/core": "workspace:*", + "@opencode-ai/sdk": "workspace:*", "@pierre/diffs": "catalog:", "@shikijs/stream": "catalog:", "@shikijs/transformers": "3.9.2", @@ -985,6 +836,8 @@ "@solid-primitives/event-listener": "2.4.5", "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.3", + "@solidjs/meta": "catalog:", + "@solidjs/router": "catalog:", "diff": "catalog:", "dompurify": "3.3.1", "fuzzysort": "catalog:", @@ -1000,34 +853,27 @@ "remeda": "catalog:", "remend": "catalog:", "shiki": "catalog:", + "solid-js": "catalog:", "solid-list": "catalog:", - "solid-sonner": "catalog:", "strip-ansi": "7.1.2", }, "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.18.11", + "version": "1.17.9", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -1070,19 +916,17 @@ ], "patchedDependencies": { "@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", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.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", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", + "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.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", + "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", + "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.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:", @@ -1093,7 +937,6 @@ }, "catalog": { "@cloudflare/workers-types": "4.20251008.0", - "@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", @@ -1104,9 +947,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.4.5", - "@opentui/keymap": "0.4.5", - "@opentui/solid": "0.4.5", + "@opentui/core": "0.3.4", + "@opentui/keymap": "0.3.4", + "@opentui/solid": "0.3.4", "@pierre/diffs": "1.2.10", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1117,7 +960,7 @@ "@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", + "@tanstack/solid-virtual": "3.13.28", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", "@types/bun": "1.3.13", @@ -1137,7 +980,7 @@ "hono": "4.10.7", "hono-openapi": "1.1.2", "luxon": "3.6.1", - "marked": "17.0.6", + "marked": "17.0.1", "marked-shiki": "1.2.1", "opentui-spinner": "0.0.7", "remeda": "2.26.0", @@ -1146,7 +989,6 @@ "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", @@ -1180,7 +1022,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.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/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/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=="], @@ -1190,7 +1032,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.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/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/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=="], @@ -1204,7 +1046,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.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/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/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=="], @@ -1220,7 +1062,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.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=="], + "@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=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -1476,10 +1318,6 @@ "@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=="], @@ -1488,20 +1326,6 @@ "@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=="], @@ -1950,10 +1774,6 @@ "@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"], @@ -1980,26 +1800,16 @@ "@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"], @@ -2044,27 +1854,27 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@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": ["@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-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="], + "@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-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="], + "@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-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="], - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="], + "@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-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="], - "@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/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/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=="], + "@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=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -2340,8 +2150,6 @@ "@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=="], @@ -2680,8 +2488,6 @@ "@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=="], @@ -2786,9 +2592,9 @@ "@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/solid-virtual": ["@tanstack/solid-virtual@3.13.28", "", { "dependencies": { "@tanstack/virtual-core": "3.17.0" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-kRuOEL5orH/rzGgxNgfgOttsgV6cgrUeupVtrHMITb5p0rZ3hnxhbu/lhKcR9+7x+EJdfUtJIb2CVC85mlw15g=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="], "@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=="], @@ -2806,18 +2612,6 @@ "@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=="], @@ -3054,7 +2848,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.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -3258,7 +3052,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.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="], + "bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="], "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], @@ -3850,13 +3644,13 @@ "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], - "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#83c0a07", {}, "anomalyco-ghostty-web-83c0a07", "sha512-Lf2v1agHkVUpMpHBWWuCZrhOEmcwwin5/Hboc9rZwQ7/CKkIh5rU1r1CvfLlhkMoFv+ed8z52RZ8hkzGZZj3MQ=="], + "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#20bd361", {}, "anomalyco-ghostty-web-20bd361", "sha512-dW0nwaiBBcun9y5WJSvm3HxDLe5o9V0xLCndQvWonRVubU8CS1PHxZpLffyPt1YujPWC13ez03aWxcuKBPYYGQ=="], "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - "gitlab-ai-provider": ["gitlab-ai-provider@6.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=="], + "gitlab-ai-provider": ["gitlab-ai-provider@6.9.3", "", { "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-lWo6b6es5+k9iXaDIvE9ECzyK4zfEza4+dQ5FN8SJpEuVRi3ZBCpHIOTa32QoYEDCBaiPh+tcyca86PfNodmlg=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], @@ -4318,7 +4112,7 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], "marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="], @@ -5114,26 +4908,18 @@ "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.2.0", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-YM92o+jvpzX3XGaD4rLYmq/Kc2ZVh47GSCLEufHBFQQIurvZTs8SoGJxO8BJGNDxBKdcS8F3dYhW1SDXp4BNjA=="], + "solid-presence": ["solid-presence@0.1.8", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA=="], "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=="], @@ -5356,12 +5142,22 @@ "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], - "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=="], + "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=="], "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=="], @@ -5470,7 +5266,7 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "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=="], + "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=="], "verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="], @@ -5614,6 +5410,8 @@ "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=="], @@ -5662,11 +5460,7 @@ "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], - "@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], - - "@ai-sdk/azure/@ai-sdk/provider": ["@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/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/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=="], @@ -5678,9 +5472,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.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + "@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-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/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/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], @@ -5706,9 +5500,7 @@ "@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": ["@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/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/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=="], @@ -5722,11 +5514,7 @@ "@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.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=="], + "@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=="], "@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=="], @@ -5740,8 +5528,6 @@ "@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=="], @@ -5950,12 +5736,8 @@ "@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=="], @@ -6034,36 +5816,26 @@ "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], - "@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": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], "@opencode-ai/core/@ai-sdk/openai-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/core/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "@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/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], "@opencode-ai/llm/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="], "@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/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=="], "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], "@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=="], @@ -6180,16 +5952,8 @@ "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=="], @@ -6218,8 +5982,6 @@ "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=="], @@ -6310,8 +6072,6 @@ "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=="], @@ -6376,8 +6136,6 @@ "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=="], @@ -6410,9 +6168,7 @@ "nypm/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], - "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": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], "opencode/@ai-sdk/openai-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=="], @@ -6420,8 +6176,6 @@ "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=="], @@ -6488,8 +6242,6 @@ "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=="], @@ -6508,8 +6260,6 @@ "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=="], @@ -6528,24 +6278,20 @@ "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.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/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/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.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=="], + "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=="], "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=="], @@ -6626,8 +6372,6 @@ "@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=="], @@ -6920,10 +6664,6 @@ "@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=="], @@ -6970,8 +6710,6 @@ "@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=="], @@ -6994,18 +6732,6 @@ "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=="], @@ -7150,16 +6876,6 @@ "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=="], @@ -7192,10 +6908,6 @@ "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=="], @@ -7408,8 +7120,6 @@ "@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=="], @@ -7426,12 +7136,6 @@ "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=="], @@ -7500,10 +7204,6 @@ "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/infra/console.ts b/infra/console.ts index 79556f5e0c..0a304a7be3 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -256,7 +256,6 @@ 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, @@ -288,12 +287,8 @@ new sst.cloudflare.x.SolidStart("Console", { server: { placement: { region: "aws:us-east-2" }, transform: { - worker: (args) => { - args.compatibilityFlags = $resolve(args.compatibilityFlags).apply((flags) => [ - ...(flags ?? []), - "global_fetch_strictly_public", - ]) - args.tailConsumers = [{ service: logProcessor.nodes.worker.scriptName }] + worker: { + tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }], }, }, }, diff --git a/infra/enterprise.ts b/infra/enterprise.ts index cd2a96d9b3..dc336a6843 100644 --- a/infra/enterprise.ts +++ b/infra/enterprise.ts @@ -7,7 +7,6 @@ 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 d4e46168e3..dd11ca38f9 100644 --- a/infra/lake.ts +++ b/infra/lake.ts @@ -67,10 +67,6 @@ 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 2df3e60776..65ada2f1f6 100644 --- a/infra/secret.ts +++ b/infra/secret.ts @@ -9,7 +9,6 @@ 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 f0db797448..8d80eefed8 100644 --- a/infra/stage.ts +++ b/infra/stage.ts @@ -8,25 +8,6 @@ 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 10d37119f0..10a5fb20bc 100644 --- a/infra/stats.ts +++ b/infra/stats.ts @@ -42,12 +42,11 @@ 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 }, @@ -57,7 +56,6 @@ 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 }, @@ -86,7 +84,7 @@ const inferenceEventTable = new aws.s3tables.Table( }, }, }, - { deleteBeforeReplace: $app.stage !== "production", ignoreChanges: ["metadata"] }, + { deleteBeforeReplace: $app.stage !== "production" }, ) export const inferenceEvent = new sst.Linkable("InferenceEvent", { @@ -185,9 +183,7 @@ export const statSync = new sst.aws.Service("StatsSyncService", { cluster: lakeCluster, architecture: "arm64", cpu: "0.25 vCPU", - // 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", + memory: "0.5 GB", image: { context: ".", dockerfile: "packages/stats/server/Dockerfile", diff --git a/nix/desktop.nix b/nix/desktop.nix index 2df62f7a1c..d0d7fa7eca 100644 --- a/nix/desktop.nix +++ b/nix/desktop.nix @@ -8,8 +8,6 @@ makeWrapper, writableTmpDirAsHomeHook, autoPatchelfHook, - copyDesktopItems, - makeDesktopItem, opencode, }: let @@ -29,12 +27,9 @@ stdenv.mkDerivation (finalAttrs: { nodejs makeWrapper writableTmpDirAsHomeHook - ] - ++ lib.optionals stdenv.hostPlatform.isLinux [ + ] ++ lib.optionals stdenv.hostPlatform.isLinux [ autoPatchelfHook - copyDesktopItems - ] - ++ lib.optionals stdenv.hostPlatform.isDarwin [ + ] ++ lib.optionals stdenv.hostPlatform.isDarwin [ # Ad-hoc sign the .app: --config.mac.identity=null below skips signing. darwin.autoSignDarwinBinariesHook ]; @@ -43,37 +38,20 @@ 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"; }; - 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 - ''; + # 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 + ''; preBuild = '' cp -r "${electron.dist}" $HOME/.electron-dist @@ -98,38 +76,27 @@ 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 - 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 - ''; + 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 + ''; autoPatchelfIgnoreMissingDeps = [ "libc.musl-x86_64.so.1" diff --git a/nix/hashes.json b/nix/hashes.json index 25f0e76812..ef9387b869 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-a7NyYa9vRUEqDfZNDPXXmFO58RDEgioyuGSl5CPBvxo=", - "aarch64-linux": "sha256-l4OJtSEllHvRhktjcaJYwkXBSaJvsIoyoLusbZfYMcM=", - "aarch64-darwin": "sha256-IIl0BQGs1/HLFh0auiQjiwfSQ2nfHcK2G2BAphYW59c=", - "x86_64-darwin": "sha256-vVeuPyd4ZIRYrHouphTuEb4rkRZLKKTAHe840jNh9rU=" + "x86_64-linux": "sha256-oWSGu+SP66Aquy/0Vaq7Bgp8404ZdOWbQX+O7h3jxHU=", + "aarch64-linux": "sha256-UsS0+c+GwtIukmWwQeFbY/3Oaz3t4Q7C6cFMGkmlyAY=", + "aarch64-darwin": "sha256-CArz92ewPmXO+ORFCBkCH8LzMpU/DjyaO4ic7QL0UpI=", + "x86_64-darwin": "sha256-rhnz9gmG6L06wIzfMhTaXDDEf6IbMD32CavqwXoqcUs=" } } diff --git a/nix/opencode.nix b/nix/opencode.nix index a22f7d3d24..82a7b54c40 100644 --- a/nix/opencode.nix +++ b/nix/opencode.nix @@ -27,13 +27,6 @@ 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 2a8eef6356..49507128d6 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "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": { @@ -40,14 +39,13 @@ "@octokit/rest": "22.0.0", "@hono/standard-validator": "0.2.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.4.5", - "@opentui/keymap": "0.4.5", - "@opentui/solid": "0.4.5", - "@tanstack/solid-virtual": "3.13.32", + "@opentui/core": "0.3.4", + "@opentui/keymap": "0.3.4", + "@opentui/solid": "0.3.4", + "@tanstack/solid-virtual": "3.13.28", "@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", @@ -70,7 +68,7 @@ "hono-openapi": "1.1.2", "fuzzysort": "3.1.0", "luxon": "3.6.1", - "marked": "17.0.6", + "marked": "17.0.1", "marked-shiki": "1.2.1", "remend": "1.3.0", "@playwright/test": "1.59.1", @@ -90,7 +88,6 @@ "@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" } @@ -107,7 +104,7 @@ "prettier": "3.6.2", "semver": "^7.6.0", "sst": "catalog:", - "turbo": "2.10.2" + "turbo": "2.8.13" }, "dependencies": { "@aws-sdk/client-s3": "3.933.0", @@ -144,20 +141,18 @@ "@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", - "@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", + "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.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", + "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.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" + "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch" } } diff --git a/packages/app/V1_API_MIGRATION.md b/packages/app/V1_API_MIGRATION.md deleted file mode 100644 index 2850f10740..0000000000 --- a/packages/app/V1_API_MIGRATION.md +++ /dev/null @@ -1,220 +0,0 @@ -# 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/README.md b/packages/app/e2e/performance/README.md index ce868d573b..afa3108d5c 100644 --- a/packages/app/e2e/performance/README.md +++ b/packages/app/e2e/performance/README.md @@ -18,8 +18,6 @@ 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 diff --git a/packages/app/e2e/performance/timeline-stability/README.md b/packages/app/e2e/performance/timeline-stability/README.md deleted file mode 100644 index 3e9def2757..0000000000 --- a/packages/app/e2e/performance/timeline-stability/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# 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 deleted file mode 100644 index 7ce1be9064..0000000000 --- a/packages/app/e2e/performance/timeline-stability/adverse.spec.ts +++ /dev/null @@ -1,250 +0,0 @@ -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 deleted file mode 100644 index b3438b77c0..0000000000 --- a/packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts +++ /dev/null @@ -1,192 +0,0 @@ -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 deleted file mode 100644 index 3455438a73..0000000000 --- a/packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index e0f0d72233..0000000000 --- a/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -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 deleted file mode 100644 index 798bf0df3b..0000000000 --- a/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index b003645e64..0000000000 --- a/packages/app/e2e/performance/timeline-stability/fixture.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -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 deleted file mode 100644 index df67da5a66..0000000000 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ /dev/null @@ -1,566 +0,0 @@ -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 deleted file mode 100644 index 8cdf4fa8cf..0000000000 --- a/packages/app/e2e/performance/timeline-stability/interaction.spec.ts +++ /dev/null @@ -1,242 +0,0 @@ -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 deleted file mode 100644 index 40688429a3..0000000000 --- a/packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts +++ /dev/null @@ -1,157 +0,0 @@ -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 deleted file mode 100644 index 7891eba5bd..0000000000 --- a/packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index cfa4d92613..0000000000 --- a/packages/app/e2e/performance/timeline-stability/playwright.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index e67545104d..0000000000 --- a/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts +++ /dev/null @@ -1,353 +0,0 @@ -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 deleted file mode 100644 index bab1ca23b4..0000000000 --- a/packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts +++ /dev/null @@ -1,255 +0,0 @@ -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 deleted file mode 100644 index 03690ec921..0000000000 --- a/packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -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 deleted file mode 100644 index d28fdaa65f..0000000000 --- a/packages/app/e2e/performance/timeline-stability/tools.spec.ts +++ /dev/null @@ -1,198 +0,0 @@ -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 deleted file mode 100644 index e999e8c50a..0000000000 --- a/packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts +++ /dev/null @@ -1,272 +0,0 @@ -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 deleted file mode 100644 index a07e8f3d89..0000000000 --- a/packages/app/e2e/performance/timeline/first-navigation-benchmark.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -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 deleted file mode 100644 index db38678046..0000000000 --- a/packages/app/e2e/performance/timeline/first-navigation-metrics.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 0e7ef45f2f..0000000000 --- a/packages/app/e2e/performance/timeline/first-navigation-probe.ts +++ /dev/null @@ -1,86 +0,0 @@ -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 deleted file mode 100644 index 9b74c7d6bf..0000000000 --- a/packages/app/e2e/performance/timeline/home-tab-navigation-benchmark.spec.ts +++ /dev/null @@ -1,114 +0,0 @@ -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 deleted file mode 100644 index b8ec858e84..0000000000 --- a/packages/app/e2e/performance/timeline/navigation-milestones.ts +++ /dev/null @@ -1,128 +0,0 @@ -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 deleted file mode 100644 index 81de0a055f..0000000000 --- a/packages/app/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts +++ /dev/null @@ -1,312 +0,0 @@ -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 deleted file mode 100644 index 838af17c93..0000000000 --- a/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts +++ /dev/null @@ -1,172 +0,0 @@ -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 index 051b29d0cd..741084751f 100644 --- a/packages/app/e2e/performance/timeline/session-tab-flash.spec.ts +++ b/packages/app/e2e/performance/timeline/session-tab-flash.spec.ts @@ -47,21 +47,3 @@ benchmark("samples cached session repaint after the click", async ({ page, repor 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-switch-benchmark.spec.ts b/packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts index 48d4d3deb0..2e80d70381 100644 --- a/packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts +++ b/packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts @@ -2,13 +2,7 @@ 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 { installStressSessionTabs, mockStressTimeline, stressSessionHref } from "./timeline-test-helpers" import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe" type Result = Awaited> @@ -26,41 +20,8 @@ benchmark("benchmarks cold and hot session tab switching", async ({ browser, rep 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) +async function trial(page: Page, mode: "cold" | "hot") { + await mockStressTimeline(page) await installStressSessionTabs(page) if (mode === "hot") { await page.goto(stressSessionHref(fixture.targetID)) @@ -72,10 +33,6 @@ async function trial( 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) @@ -113,15 +70,6 @@ function summarize(results: Record<"cold" | "hot", Result[]>) { ) } -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() @@ -129,16 +77,3 @@ async function switchSession(page: Page, sessionID: string, title: string) { 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 index 5298b9b0b7..e315c2ad43 100644 --- a/packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts +++ b/packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts @@ -4,15 +4,7 @@ export type SessionSwitchSample = { 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[]) { @@ -31,10 +23,6 @@ export function classifySessionSwitch(samples: SessionSwitchSample[]) { (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 ?? []))], } } @@ -43,8 +31,7 @@ export function isCorrectDestination(sample: SessionSwitchSample) { sample.destination.length > 0 && sample.source.length === 0 && sample.last && - sample.requiredPartVisible !== false && - (sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1) + 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 index 955da80367..14f9d2d003 100644 --- a/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts +++ b/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts @@ -8,56 +8,19 @@ type SessionSwitchProbe = { async function installSessionSwitchProbe( page: Page, - input: { - destinationIDs: string[] - sourceIDs: string[] - lastID: string - requiredPartID?: string - requireBottomAnchor?: boolean - href: string - }, + input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string }, ) { - await page.evaluate(({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, href }) => { + await page.evaluate(({ destinationIDs, sourceIDs, lastID, 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]"), ) @@ -73,13 +36,6 @@ async function installSessionSwitchProbe( 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, @@ -87,22 +43,10 @@ async function installSessionSwitchProbe( 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, - }) + samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false }) } requestAnimationFrame(sample) }, 0) @@ -113,9 +57,6 @@ async function installSessionSwitchProbe( 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 }, @@ -142,8 +83,7 @@ async function waitForStableSessionSwitch(page: Page) { sample.destination.length > 0 && sample.source.length === 0 && sample.last && - sample.requiredPartVisible !== false && - (sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1), + Math.abs(sample.bottomErrorPx ?? Infinity) <= 1, ) ) }) @@ -161,27 +101,13 @@ async function collectSessionSwitchResult(page: Page) { export async function measureSessionSwitch( page: Page, - input: { - destinationIDs: string[] - sourceIDs: string[] - lastID: string - requiredPartID?: string - requireBottomAnchor?: boolean - href: string - switch: () => Promise - }, + input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; 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() - }) - } + await run() + await waitForStableSessionSwitch(page) + return collectSessionSwitchResult(page) } export async function waitForStableTimeline(page: Page, lastID: string) { diff --git a/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts b/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts index a86a55cff2..6353416d50 100644 --- a/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts +++ b/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts @@ -93,53 +93,37 @@ const assistantMessage = { parts: [editPart], } -export async function setupTimelineBenchmark( - page: Page, - options: { - historyTurns: number - eventBatch: number - newLayoutDesigns?: boolean - vcsDiff?: unknown[] - turnDiffs?: unknown[] - }, -) { +export async function setupTimelineBenchmark(page: Page, options: { historyTurns: number; eventBatch: number }) { 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, + userMessage, 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.addInitScript(() => { + localStorage.setItem( + "settings.v3", + JSON.stringify({ + general: { + editToolPartsExpanded: true, + shellToolPartsExpanded: true, + showReasoningSummaries: true, + showSessionProgressBar: true, + }, + }), + ) + }) 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() diff --git a/packages/app/e2e/performance/timeline/session-timeline-benchmark.spec.ts b/packages/app/e2e/performance/timeline/session-timeline-benchmark.spec.ts index 1a52bc8aa3..64d79283f0 100644 --- a/packages/app/e2e/performance/timeline/session-timeline-benchmark.spec.ts +++ b/packages/app/e2e/performance/timeline/session-timeline-benchmark.spec.ts @@ -1,4 +1,3 @@ -import type { Page } from "@playwright/test" import { benchmark, benchmarkDiagnostics, expect } from "../benchmark" import { buildInitialStreamEvent, @@ -7,300 +6,80 @@ import { 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() + benchmark.setTimeout(480_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 fixture = await setupTimelineBenchmark(page, { historyTurns, - eventBatch: 1, - newLayoutDesigns: true, - vcsDiff: diffs, + eventBatch, }) - fixture.transport.enqueue(buildInitialStreamEvent(1)) + 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 open = await measureReviewPaneLoad(page, diffs[0]!.file) - const switches = [] - for (const diff of diffs.slice(1, 4)) switches.push(await measureReviewNextFile(page, diff.file)) + 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: 420_000 }, + ) + 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() report( { - open, - switches, + endToEndInitialContentObservedMs: initialContentObservedMs, + ...metrics, + deliveredDeltas: delivered, + pendingDeltas: fixture.transport.pendingCount(), }, { + cpuThrottle, + profileCPU, + profileVisual, + minimal, + queuedDeltas: deltas.length, historyTurns, - reviewDiffs: diffs.length, + eventBatch, }, ) + + await profile.reset() }) }) - -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-stress.fixture.ts b/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts index 529081a1d9..e5c353e4cd 100644 --- a/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts +++ b/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts @@ -23,7 +23,6 @@ const words = [ 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" } @@ -127,11 +126,9 @@ function toolPart( tool: string, input: Record, outputLength = 160, - metadataOverride?: Record, ): MessagePart { const metadata = - metadataOverride ?? - (tool === "apply_patch" + tool === "apply_patch" ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] } : tool === "edit" || tool === "write" ? { @@ -141,7 +138,7 @@ function toolPart( } : tool === "question" ? { answers: [["Proceed"], ["Keep sample output"]] } - : {}) + : {} return { id: id(`prt_tool_${tool}_${partIndex}`, index), type: "tool", @@ -247,25 +244,7 @@ function turn(index: number): Message[] { 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)]), + assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]), ]).flat() function renderable(part: MessagePart) { @@ -319,32 +298,19 @@ export const fixture = { 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 }, + messages: { [sourceID]: sourceMessages, [targetID]: targetMessages }, 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) diff --git a/packages/app/e2e/performance/timeline/timeline-test-helpers.ts b/packages/app/e2e/performance/timeline/timeline-test-helpers.ts index 5028373c55..dc7e173071 100644 --- a/packages/app/e2e/performance/timeline/timeline-test-helpers.ts +++ b/packages/app/e2e/performance/timeline/timeline-test-helpers.ts @@ -1,7 +1,7 @@ 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" +import { fixture } from "./session-timeline-stress.fixture" export async function installTimelineSettings(page: Page) { await page.addInitScript(() => { @@ -9,38 +9,30 @@ export async function installTimelineSettings(page: Page) { "settings.v3", JSON.stringify({ general: { - newLayoutDesigns: true, editToolPartsExpanded: true, shellToolPartsExpanded: true, showReasoningSummaries: true, + showSessionProgressBar: true, }, }), ) }) } -export function mockStressTimeline( - page: Page, - input?: { - onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void - vcsDiff?: unknown[] - }, -) { +export function mockStressTimeline(page: Page) { return mockOpenCodeServer(page, { sessions: fixture.sessions, provider: fixture.provider, directory: fixture.directory, project: fixture.project, - pageMessages, - onMessages: input?.onMessages, - vcsDiff: input?.vcsDiff, + pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }), }) } -export async function installStressSessionTabs(page: Page, input?: { draftID?: string; sessionIDs?: string[] }) { - const server = stressServer() +export async function installStressSessionTabs(page: Page) { + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` await page.addInitScript( - ({ directory, sessionIDs, dirBase64, server, draftID }) => { + ({ directory, sourceID, targetID, dirBase64, server }) => { localStorage.setItem( "opencode.global.dat:server", JSON.stringify({ @@ -49,86 +41,27 @@ export async function installStressSessionTabs(page: Page, input?: { draftID?: s }), ) localStorage.setItem( - "opencode.window.browser.dat:tabs", - JSON.stringify([ - ...sessionIDs.map((sessionId) => ({ + "opencode.global.dat:tabs", + JSON.stringify( + [sourceID, targetID].map((sessionId) => ({ type: "session", server, dirBase64, sessionId, })), - ...(draftID ? [{ type: "draft", draftID, server, directory }] : []), - ]), + ), ) }, { directory: fixture.directory, - sessionIDs: input?.sessionIDs ?? [fixture.sourceID, fixture.targetID], + sourceID: fixture.sourceID, + targetID: 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(" ") + return `/${base64Encode(fixture.directory)}/session/${sessionID}` } diff --git a/packages/app/e2e/performance/unit/first-navigation-metrics.test.ts b/packages/app/e2e/performance/unit/first-navigation-metrics.test.ts deleted file mode 100644 index c0e5474d6e..0000000000 --- a/packages/app/e2e/performance/unit/first-navigation-metrics.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 83308c0a86..0000000000 --- a/packages/app/e2e/performance/unit/mock-server.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index e22be67063..0000000000 --- a/packages/app/e2e/performance/unit/navigation-milestones.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -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-switch-metrics.test.ts b/packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts index 4b824d9dfb..dd771b7d57 100644 --- a/packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts +++ b/packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts @@ -52,35 +52,3 @@ test("reports missing correctness without throwing", () => { 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 deleted file mode 100644 index d5d7f09a69..0000000000 --- a/packages/app/e2e/performance/unit/session-tab-switch-probe.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -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/timeline-test-helpers.test.ts b/packages/app/e2e/performance/unit/timeline-test-helpers.test.ts deleted file mode 100644 index 98b661d91b..0000000000 --- a/packages/app/e2e/performance/unit/timeline-test-helpers.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 2fc3be3166..0000000000 --- a/packages/app/e2e/performance/unit/visual-stability.test.ts +++ /dev/null @@ -1,392 +0,0 @@ -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 deleted file mode 100644 index f09a2c7b63..0000000000 --- a/packages/app/e2e/regression/cross-server-tab-close.spec.ts +++ /dev/null @@ -1,153 +0,0 @@ -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 deleted file mode 100644 index 136232c211..0000000000 --- a/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts +++ /dev/null @@ -1,150 +0,0 @@ -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 deleted file mode 100644 index 45cd64adcc..0000000000 --- a/packages/app/e2e/regression/legacy-new-session.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index a17b8c0817..0000000000 --- a/packages/app/e2e/regression/new-session-panel-corner.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -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 deleted file mode 100644 index 37739d2fbb..0000000000 --- a/packages/app/e2e/regression/open-file-expand-folder.spec.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test } from "@playwright/test" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectSessionTitle } from "../utils/waits" - -const directory = "C:/OpenCode/OpenFileExpand" -const projectID = "proj_open_file_expand" -const sessionID = "ses_open_file_expand" -const title = "Open file expand" -const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` - -test.use({ viewport: { width: 1440, height: 900 } }) - -test("expands a folder whose path has a trailing Windows separator", async ({ page }) => { - await mockOpenCodeServer(page, { - directory, - project: { - id: projectID, - worktree: directory, - vcs: "git", - name: "open-file-expand", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - }, - provider: { - all: [ - { - id: "opencode", - name: "OpenCode", - models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, - }, - ], - connected: ["opencode"], - default: { providerID: "opencode", modelID: "test" }, - }, - sessions: [ - { - id: sessionID, - slug: sessionID, - projectID, - directory, - title, - version: "dev", - time: { created: 1700000000000, updated: 1700000000000 }, - }, - ], - vcsDiff: [], - fileList: (path) => { - if (path === "frontend\\" || path === "frontend") { - return [ - { - name: "app.ts", - path: "frontend\\app.ts", - absolute: `${directory}/frontend/app.ts`, - type: "file" as const, - ignored: false, - }, - ] - } - if (path) return [] - return [ - { - name: "frontend", - path: "frontend\\", - absolute: `${directory}/frontend`, - type: "directory" as const, - ignored: false, - }, - { - name: "README.md", - path: "README.md", - absolute: `${directory}/README.md`, - type: "file" as const, - ignored: false, - }, - ] - }, - fileContent: (path) => ({ type: "text", content: `contents:${path}` }), - pageMessages: () => ({ items: [] }), - }) - - await page.addInitScript( - ({ directory, server, sessionID }) => { - localStorage.setItem( - "settings.v3", - JSON.stringify({ general: { newLayoutDesigns: true, shouldDisplayTabsToast: false } }), - ) - localStorage.setItem( - "opencode.global.dat:server", - JSON.stringify({ - projects: { local: [{ worktree: directory, expanded: true }] }, - lastProject: { local: directory }, - }), - ) - localStorage.setItem( - "opencode.global.dat:layout", - JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), - ) - localStorage.setItem( - "opencode.global.dat:review-panel-v2", - JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }), - ) - localStorage.setItem( - "opencode.window.browser.dat:tabs", - JSON.stringify([{ type: "session", server, sessionId: sessionID }]), - ) - }, - { directory, server, sessionID }, - ) - - await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) - await expectSessionTitle(page, title) - - const panel = page.locator("#review-panel") - await panel.getByRole("button", { name: "Open file" }).click() - await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") - - const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]') - await expect(sidebar).toBeVisible() - - const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]') - await expect(frontendRow).toBeVisible() - await expect(frontendRow).toHaveAttribute("aria-expanded", "false") - await frontendRow.click() - await expect(frontendRow).toHaveAttribute("aria-expanded", "true") - - const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]') - await expect(appRow).toBeVisible() - await appRow.click() - await expect(panel.getByRole("tab", { name: "app.ts" })).toHaveAttribute("data-selected", "") - await expect(panel.getByText("contents:frontend/app.ts", { exact: true })).toBeVisible() -}) diff --git a/packages/app/e2e/regression/project-picker-recent-search.spec.ts b/packages/app/e2e/regression/project-picker-recent-search.spec.ts deleted file mode 100644 index 2cdb0b4a03..0000000000 --- a/packages/app/e2e/regression/project-picker-recent-search.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { expect, test } from "@playwright/test" -import type { Page } from "@playwright/test" -import { fixture, pageMessages } from "../smoke/session-timeline.fixture" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectAppVisible } from "../utils/waits" - -const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"] -const worktrees = NAMES.map((name) => `/opencode-demo/${name}`) - -// The sixth project sits outside the five-item recent cap, so it is only reachable if the -// dialog hands every recent project to the list filter instead of a pre-truncated slice. -const OUTSIDE_CAP = "foxtrot-docs" - -// Dialog rows carry data-directory-path; the sidebar project list does not, so this -// scopes assertions to the picker instead of matching the sidebar entry of the same name. -const rows = (page: Page) => page.locator("[data-directory-path]") -const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`) - -async function openProjectDialog(page: Page) { - await mockOpenCodeServer(page, { - sessions: fixture.sessions, - provider: fixture.provider, - directory: fixture.directory, - project: fixture.project, - pageMessages, - fileList: () => [], - findFiles: () => [], - }) - await page.addInitScript((dirs) => { - localStorage.setItem( - "opencode.global.dat:server", - JSON.stringify({ - projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) }, - lastProject: {}, - }), - ) - }, worktrees) - await page.goto("/") - const add = page.getByRole("button", { name: "Add project" }).first() - await expectAppVisible(add) - await add.click() - await expect(rows(page)).toHaveCount(5) - return page.getByRole("textbox").last() -} - -test("searches every recent project, not just the five most recent", async ({ page }) => { - const search = await openProjectDialog(page) - await expect(row(page, OUTSIDE_CAP)).toHaveCount(0) - - await search.fill("foxtrot") - - await expect(row(page, OUTSIDE_CAP)).toHaveCount(1) -}) - -test("still caps the idle recent list at five projects", async ({ page }) => { - await openProjectDialog(page) - - await expect(row(page, NAMES[4])).toHaveCount(1) - await expect(row(page, OUTSIDE_CAP)).toHaveCount(0) -}) diff --git a/packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts b/packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts deleted file mode 100644 index 50dcc8820b..0000000000 --- a/packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -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 9315c347c0..d12684c143 100644 --- a/packages/app/e2e/regression/prompt-thinking-level.spec.ts +++ b/packages/app/e2e/regression/prompt-thinking-level.spec.ts @@ -54,16 +54,19 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => { }) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - const composer = page.locator('[data-component="prompt-input-v2"]') + const composer = page.locator('[data-component="session-composer"]') const input = composer.locator('[data-component="prompt-input"]') - const control = composer.getByRole("button", { name: "Choose model variant" }) + const control = composer.locator('[data-component="prompt-variant-control"]') await expectAppVisible(composer) await idleComposer(page) + await expect(control).toBeHidden() + + await composer.hover() await expect(control).toBeVisible() - await control.click() - const high = page.getByRole("menuitemradio", { name: "high" }) + await control.locator('[data-action="prompt-model-variant"]').click() + const high = page.getByRole("option", { 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 deleted file mode 100644 index 35a0aa44cd..0000000000 --- a/packages/app/e2e/regression/remote-session-settings.spec.ts +++ /dev/null @@ -1,302 +0,0 @@ -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 deleted file mode 100644 index 2d9b1e2349..0000000000 --- a/packages/app/e2e/regression/remote-tab-busy.spec.ts +++ /dev/null @@ -1,131 +0,0 @@ -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 deleted file mode 100644 index dd200384d4..0000000000 --- a/packages/app/e2e/regression/review-image-flash.spec.ts +++ /dev/null @@ -1,202 +0,0 @@ -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 deleted file mode 100644 index 7850f7820a..0000000000 --- a/packages/app/e2e/regression/review-line-comment.spec.ts +++ /dev/null @@ -1,158 +0,0 @@ -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 deleted file mode 100644 index 04e6d2cced..0000000000 --- a/packages/app/e2e/regression/review-open-file.spec.ts +++ /dev/null @@ -1,163 +0,0 @@ -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 deleted file mode 100644 index 0d6756201e..0000000000 --- a/packages/app/e2e/regression/review-state-persistence.spec.ts +++ /dev/null @@ -1,153 +0,0 @@ -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 deleted file mode 100644 index 8634166e51..0000000000 --- a/packages/app/e2e/regression/review-tab-switch.spec.ts +++ /dev/null @@ -1,147 +0,0 @@ -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 deleted file mode 100644 index 79b564820e..0000000000 --- a/packages/app/e2e/regression/review-terminal-stacked.spec.ts +++ /dev/null @@ -1,306 +0,0 @@ -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 3319514df6..4a3855122a 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("**/api/path?*", async (route) => { - if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback() + await page.route("**/path?*", async (route) => { + if (!new URL(route.request().url()).searchParams.has("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 deleted file mode 100644 index 5ea9d4f761..0000000000 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ /dev/null @@ -1,221 +0,0 @@ -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 deleted file mode 100644 index 598763c022..0000000000 --- a/packages/app/e2e/regression/session-timeline-accessibility.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 5b6e0b127b..7935b10014 100644 --- a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts @@ -261,6 +261,7 @@ async function configurePage(page: Page) { editToolPartsExpanded: true, shellToolPartsExpanded: true, showReasoningSummaries: true, + showSessionProgressBar: true, }, }), ) 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 a9a4738da9..745dee10e5 100644 --- a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts +++ b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts @@ -1,13 +1,6 @@ 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" @@ -43,82 +36,6 @@ test.describe("regression: session timeline context group resize", () => { 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) { @@ -130,6 +47,7 @@ async function configurePage(page: Page) { editToolPartsExpanded: true, shellToolPartsExpanded: true, showReasoningSummaries: true, + showSessionProgressBar: true, }, }), ) @@ -211,7 +129,7 @@ async function sampleExpansion(page: Page) { ) } -function turn(index: number, target: boolean, status: "running" | "completed" = "completed"): Message[] { +function turn(index: number, target: boolean): Message[] { const userID = id("msg_user", index) const assistantID = id("msg_assistant", index) return [ @@ -246,22 +164,10 @@ function turn(index: number, target: boolean, status: "running" | "completed" = }, parts: target ? [ - 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), + 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" }), { id: followingTextID, sessionID, @@ -283,13 +189,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" = ] } -function contextTool( - partID: string, - messageID: string, - tool: string, - input: Record, - status: "running" | "completed" = "completed", -) { +function contextTool(partID: string, messageID: string, tool: string, input: Record) { return { id: partID, sessionID, @@ -298,7 +198,7 @@ function contextTool( callID: `call_${partID}`, tool, state: { - status, + status: "completed", input, output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`, title: input.filePath || input.path || input.pattern || "completed", @@ -308,19 +208,13 @@ function contextTool( } } -async function mockServer( - page: Page, - events: { directory: string; payload: Record }[] = [], - fixtureMessages = messages, -) { +async function mockServer(page: Page) { await mockOpenCodeServer(page, { directory, project: project(), provider: provider(), sessions: [session()], - pageMessages: () => ({ items: fixtureMessages }), - events: () => events.splice(0, 1), - eventRetry: 50, + pageMessages: () => ({ items: messages }), }) } diff --git a/packages/app/e2e/regression/session-timeline-context-state.spec.ts b/packages/app/e2e/regression/session-timeline-context-state.spec.ts deleted file mode 100644 index 37878325e1..0000000000 --- a/packages/app/e2e/regression/session-timeline-context-state.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index f07da121c6..0000000000 --- a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index cb228c13c7..0000000000 --- a/packages/app/e2e/regression/session-timeline-file-state.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index e5ef7998ea..0000000000 --- a/packages/app/e2e/regression/session-timeline-history-root.spec.ts +++ /dev/null @@ -1,242 +0,0 @@ -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 deleted file mode 100644 index b303071c87..0000000000 --- a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +++ /dev/null @@ -1,111 +0,0 @@ -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 deleted file mode 100644 index 3901f8865e..0000000000 --- a/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index b1aabcc32c..0000000000 --- a/packages/app/e2e/regression/session-timeline-projection.spec.ts +++ /dev/null @@ -1,287 +0,0 @@ -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 deleted file mode 100644 index 7c0864e584..0000000000 --- a/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index ad35eef601..0000000000 --- a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 54139cc371..0000000000 --- a/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts +++ /dev/null @@ -1,228 +0,0 @@ -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 deleted file mode 100644 index 99f1acf270..0000000000 --- a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -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 deleted file mode 100644 index 63646f4454..0000000000 --- a/packages/app/e2e/regression/session-timeline-tool-state.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -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 deleted file mode 100644 index 778ff3a3af..0000000000 --- a/packages/app/e2e/regression/session-timeline-transport.spec.ts +++ /dev/null @@ -1,116 +0,0 @@ -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 deleted file mode 100644 index 55e7121275..0000000000 --- a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts +++ /dev/null @@ -1,190 +0,0 @@ -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 deleted file mode 100644 index 019cc156ec..0000000000 --- a/packages/app/e2e/regression/subagent-child-navigation.spec.ts +++ /dev/null @@ -1,203 +0,0 @@ -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 deleted file mode 100644 index b969b590d8..0000000000 --- a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts +++ /dev/null @@ -1,159 +0,0 @@ -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 deleted file mode 100644 index 99bf689085..0000000000 --- a/packages/app/e2e/regression/terminal-composer-focus.spec.ts +++ /dev/null @@ -1,227 +0,0 @@ -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 deleted file mode 100644 index 8e08d60ff2..0000000000 --- a/packages/app/e2e/regression/terminal-hidden.spec.ts +++ /dev/null @@ -1,117 +0,0 @@ -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 deleted file mode 100644 index 165920753c..0000000000 --- a/packages/app/e2e/regression/terminal-tab-switch.spec.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "@playwright/test" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectSessionTitle } from "../utils/waits" - -const directory = "C:/OpenCode/TerminalTabSwitch" -const projectID = "proj_terminal_tab_switch" -const sessionA = "ses_terminal_tab_a" -const sessionB = "ses_terminal_tab_b" -const titleA = "Alpha session" -const titleB = "Beta session" -const ptyID = "pty_tab_switch" -const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` -// Marks the terminal DOM node so a remount (fresh node) is detectable. -const PROBE = "original" - -test.use({ viewport: { width: 1440, height: 900 } }) - -// Terminals are workspace-scoped: switching between session tabs in the same -// workspace must keep the terminal mounted and its PTY connection open instead -// of tearing it down and reconnecting. -test("keeps the terminal session alive when switching session tabs in a workspace", async ({ page }) => { - const connections = await setup(page) - - await page.goto(sessionHref(sessionA)) - await expectSessionTitle(page, titleA) - - await page.keyboard.press("Control+Backquote") - const terminal = page.locator('[data-component="terminal"]') - await expect(terminal).toBeVisible() - await expect.poll(() => connections.length).toBe(1) - const connection = new URL(connections[0]!) - expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`) - expect(connection.searchParams.get("location[directory]")).toBe(directory) - expect(connection.searchParams.get("ticket")).toBeNull() - await writeProbe(page) - - await switchTab(page, titleB) - await expectSessionTitle(page, titleB) - await expect(terminal).toBeVisible() - expect(await readProbe(page)).toBe(PROBE) - expect(connections.length).toBe(1) - - await switchTab(page, titleA) - await expectSessionTitle(page, titleA) - await expect(terminal).toBeVisible() - expect(await readProbe(page)).toBe(PROBE) - expect(connections.length).toBe(1) -}) - -type Probed = HTMLElement & { __e2eProbe?: string } - -async function switchTab(page: Page, title: string) { - await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click() -} - -async function writeProbe(page: Page) { - await page.locator('[data-component="terminal"]').evaluate((el, probe) => { - ;(el as Probed).__e2eProbe = probe - }, PROBE) -} - -async function readProbe(page: Page) { - return page.locator('[data-component="terminal"]').evaluate((el) => (el as Probed).__e2eProbe) -} - -async function setup(page: Page) { - await mockOpenCodeServer(page, { - protocol: "v2", - directory, - project: { - id: projectID, - worktree: directory, - vcs: "git", - name: "terminal-tab-switch", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - }, - provider: { - all: [ - { - id: "opencode", - name: "OpenCode", - models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, - }, - ], - connected: ["opencode"], - default: { providerID: "opencode", modelID: "test" }, - }, - sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], - pageMessages: () => ({ items: [] }), - }) - await page.route("**/api/pty*", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }), - }), - ) - await page.route(`**/api/pty/${ptyID}*`, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }), - }), - ) - await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => { - expect(route.request().headers()["x-opencode-ticket"]).toBe("1") - const url = new URL(route.request().url()) - expect(url.searchParams.get("location[directory]")).toBe(directory) - return route.fulfill({ - status: 200, - contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), - }) - }) - const connections: string[] = [] - await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => { - connections.push(ws.url()) - }) - - await page.addInitScript( - ({ directory, server, sessions }) => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - localStorage.setItem( - "opencode.global.dat:server", - JSON.stringify({ - projects: { local: [{ worktree: directory, expanded: true }] }, - lastProject: { local: directory }, - }), - ) - localStorage.setItem( - "opencode.window.browser.dat:tabs", - JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))), - ) - }, - { directory, server, sessions: [sessionA, sessionB] }, - ) - return connections -} - -function session(id: string, title: string, created: number) { - return { - id, - slug: id, - projectID, - directory, - title, - version: "dev", - time: { created, updated: created }, - } -} - -function sessionHref(sessionID: string) { - return `/server/${base64Encode(server)}/session/${sessionID}` -} - -function ptyLocation() { - return { directory, project: { id: projectID, directory } } -} - -function ptyInfo() { - return { id: ptyID, title: "Terminal 1", command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 } -} diff --git a/packages/app/e2e/reproduction/timeline-suspense/index.html b/packages/app/e2e/reproduction/timeline-suspense/index.html deleted file mode 100644 index 995008a9f7..0000000000 --- a/packages/app/e2e/reproduction/timeline-suspense/index.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - Timeline Suspense Reproduction - - - -
- - - diff --git a/packages/app/e2e/reproduction/timeline-suspense/main.tsx b/packages/app/e2e/reproduction/timeline-suspense/main.tsx deleted file mode 100644 index cdcd809be3..0000000000 --- a/packages/app/e2e/reproduction/timeline-suspense/main.tsx +++ /dev/null @@ -1,315 +0,0 @@ -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 deleted file mode 100644 index 7fff1737ae..0000000000 --- a/packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 3edae33a9b..0000000000 --- a/packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts +++ /dev/null @@ -1,179 +0,0 @@ -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 deleted file mode 100644 index efb75ab731..0000000000 --- a/packages/app/e2e/reproduction/timeline-suspense/vite.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -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 3dce37cafd..1fc8571db4 100644 --- a/packages/app/e2e/smoke/session-timeline.fixture.ts +++ b/packages/app/e2e/smoke/session-timeline.fixture.ts @@ -21,7 +21,6 @@ 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" @@ -140,7 +139,7 @@ function toolPart( status: "completed", input, output: lorem(index * 23 + partIndex, outputLength), - title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed", + 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 }, }, @@ -201,7 +200,9 @@ 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" }, 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 @@ -241,7 +242,6 @@ function orderedParts(message: Message) { export const fixture = { directory, - serverKey, project: { id: projectID, worktree: directory, @@ -295,7 +295,6 @@ 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 bdf3f55bdc..925614cc28 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -58,18 +58,7 @@ test.describe("smoke: session timeline", () => { 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 keys = ["prt_user_text_smoke_0032", "prt_text_2_smoke_0032", "prt_tool_apply_patch_8_smoke_0032"] const positions = () => scroller.evaluate((element, keys) => { const top = element.getBoundingClientRect().top @@ -127,7 +116,7 @@ test.describe("smoke: session timeline", () => { await page.addInitScript( ({ dirBase64, sourceID, targetID }) => { localStorage.setItem( - "opencode.window.browser.dat:tabs", + "opencode.global.dat:tabs", JSON.stringify( [sourceID, targetID].map((sessionId) => ({ type: "session", @@ -253,7 +242,7 @@ test.describe("smoke: session timeline", () => { await page.addInitScript( ({ dirBase64, sourceID, targetID }) => { localStorage.setItem( - "opencode.window.browser.dat:tabs", + "opencode.global.dat:tabs", JSON.stringify( [sourceID, targetID].map((sessionId) => ({ type: "session", @@ -338,18 +327,6 @@ 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) }) }) @@ -362,6 +339,7 @@ async function configureSmokePage(page: Page, directory: string) { editToolPartsExpanded: true, shellToolPartsExpanded: true, showReasoningSummaries: true, + showSessionProgressBar: true, }, }), ) @@ -728,7 +706,7 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin } async function switchTitlebarSession(page: Page, sessionID: string, title: string) { - const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}` + const href = `/${base64Encode(fixture.directory)}/session/${sessionID}` const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first() await expect(tab).toBeVisible() await tab.click() @@ -736,5 +714,5 @@ async function switchTitlebarSession(page: Page, sessionID: string, title: strin } async function expectSessionReady(page: Page) { - await expectAppVisible(page.getByRole("textbox", { name: "Prompt" })) + await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i })) } diff --git a/packages/app/e2e/tsconfig.json b/packages/app/e2e/tsconfig.json index 4a6046e4fa..3f1cad80cb 100644 --- a/packages/app/e2e/tsconfig.json +++ b/packages/app/e2e/tsconfig.json @@ -5,16 +5,5 @@ "rootDir": "..", "types": ["node", "bun"] }, - "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" - ] + "include": ["./**/*.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 deleted file mode 100644 index 22b8bb41fe..0000000000 --- a/packages/app/e2e/user-story/model-selection-flow.spec.ts +++ /dev/null @@ -1,97 +0,0 @@ -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 76987421b6..cf0c952431 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -1,39 +1,32 @@ import type { Page, Route } from "@playwright/test" -const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"]) -const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"]) +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"]) export interface MockServerConfig { - protocol?: "v1" | "v2" - provider: unknown | (() => unknown) - integrationMethods?: Record - onConnectKey?: (input: { integrationID: string; body: unknown }) => void - onInstanceDispose?: () => void + provider: unknown 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, @@ -57,256 +50,29 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (url.port !== targetPort && url.port !== appPort) return route.fallback() const path = url.pathname - 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 (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry) + if (path === "/global/health") return json(route, { healthy: true }) 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 ?? {}) } - 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 }, - }) - } + if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, []) 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) + const before = url.searchParams.get("before") ?? undefined 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)) + if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) const limit = Number(url.searchParams.get("limit") ?? 80) const pageData = config.pageMessages(messagesMatch[1], limit, before) 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, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined) } if (url.port === targetPort && targetPort !== appPort) return json(route, {}) @@ -314,118 +80,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }) } -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) { +function json(route: Route, body: unknown, headers?: Record) { return route.fulfill({ - status, + status: 200, contentType: "application/json", headers: { "access-control-allow-origin": "*", @@ -443,18 +100,3 @@ function sse(route: Route, events?: unknown[], retry?: number) { 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 deleted file mode 100644 index b0e3b74c6d..0000000000 --- a/packages/app/e2e/utils/sse-transport.ts +++ /dev/null @@ -1,312 +0,0 @@ -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 deleted file mode 100644 index 2c56864ed8..0000000000 --- a/packages/app/e2e/utils/visual-stability.ts +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index 902d222e1d..0000000000 --- a/packages/app/e2e/utils/visual-stability/analyzer.ts +++ /dev/null @@ -1,209 +0,0 @@ -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 deleted file mode 100644 index 3bb583a9d5..0000000000 --- a/packages/app/e2e/utils/visual-stability/capture.ts +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 6fdbf4acfc..0000000000 --- a/packages/app/e2e/utils/visual-stability/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 17f6ef1913..0000000000 --- a/packages/app/e2e/utils/visual-stability/invariant.ts +++ /dev/null @@ -1,112 +0,0 @@ -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 deleted file mode 100644 index 1b966638c9..0000000000 --- a/packages/app/e2e/utils/visual-stability/model.ts +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index abf274d359..0000000000 --- a/packages/app/e2e/utils/visual-stability/probe.ts +++ /dev/null @@ -1,226 +0,0 @@ -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 deleted file mode 100644 index 4c0b34e78a..0000000000 --- a/packages/app/e2e/utils/visual-stability/regions.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index db283072a8..0000000000 --- a/packages/app/e2e/utils/visual-stability/reporter.ts +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index 2136302071..0000000000 --- a/packages/app/e2e/utils/visual-stability/scenario.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 d71535182c..8c86360af3 100644 --- a/packages/app/index.html +++ b/packages/app/index.html @@ -1,28 +1,22 @@ - + - + OpenCode - - - - + - + -
+
diff --git a/packages/app/package.json b/packages/app/package.json index 2dd4a05d86..e4906b9ea4 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.18.11", + "version": "1.17.9", "description": "", "type": "module", "exports": { @@ -13,20 +13,18 @@ }, "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 && 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": "bun run test:unit && bun run test:virtualizer", + "test:unit": "bun test --only-failures --preload ./happydom.ts ./src", + "test:virtualizer": "bun test --conditions=browser --preload ./happydom.ts ./test-browser/solid-virtual.test.ts", + "test:unit:watch": "bun test --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: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", @@ -40,24 +38,15 @@ "@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:", @@ -82,7 +71,7 @@ "diff": "catalog:", "effect": "catalog:", "fuzzysort": "catalog:", - "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", + "ghostty-web": "github:anomalyco/ghostty-web#main", "luxon": "catalog:", "marked": "catalog:", "marked-shiki": "catalog:", @@ -90,7 +79,6 @@ "shiki": "catalog:", "solid-js": "catalog:", "solid-list": "catalog:", - "solid-presence": "0.2.0", "tailwindcss": "catalog:" } } diff --git a/packages/app/public/oc-theme-preload.js b/packages/app/public/oc-theme-preload.js index 11c9b39ec4..18846fceb6 100644 --- a/packages/app/public/oc-theme-preload.js +++ b/packages/app/public/oc-theme-preload.js @@ -15,11 +15,10 @@ 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 ? "#080808" : "#fafafa") + if (metas.length > 0) metas[0].setAttribute("content", isDark ? "#131010" : "#F8F7F7") if (themeId === "oc-2") return diff --git a/packages/app/src/addons/serialize.test.ts b/packages/app/src/addons/serialize.test.ts index d45af34c43..6828e60f84 100644 --- a/packages/app/src/addons/serialize.test.ts +++ b/packages/app/src/addons/serialize.test.ts @@ -37,14 +37,6 @@ 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 515153488c..3823fb443a 100644 --- a/packages/app/src/addons/serialize.ts +++ b/packages/app/src/addons/serialize.ts @@ -89,13 +89,6 @@ 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 // ============================================================================ @@ -551,8 +544,7 @@ export class SerializeAddon implements ITerminalAddon { return "" } - let content = !options?.excludeModes && getTerminalMode(this._terminal, 2031) ? "\u001b[?2031h" : "" - content += options?.range + let 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 f47c432e42..75f0c6b444 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -4,29 +4,18 @@ 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/session-ui/file" +import { File } from "@opencode-ai/ui/file" import { Font } from "@opencode-ai/ui/font" import { Splash } from "@opencode-ai/ui/logo" import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" -import { - type BaseRouterProps, - Navigate, - Route, - Router, - useLocation, - useNavigate, - useParams, - useSearchParams, -} from "@solidjs/router" +import { type BaseRouterProps, Navigate, Route, Router, 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, @@ -38,196 +27,135 @@ import { Show, } from "solid-js" import { Dynamic } from "solid-js/web" -import { makeEventListener } from "@solid-primitives/event-listener" -import { CommandProvider, useCommand, type CommandOption } from "@/context/command" +import { CommandProvider } from "@/context/command" import { CommentsProvider } from "@/context/comments" import { FileProvider } from "@/context/file" import { ServerSDKProvider } from "@/context/server-sdk" -import { ServerSyncProvider, useServerSync } from "@/context/server-sync" -import { GlobalProvider, useGlobal } from "@/context/global" +import { ServerSyncProvider } from "@/context/server-sync" +import { GlobalProvider } from "@/context/global" import { HighlightsProvider } from "@/context/highlights" import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LayoutProvider } from "@/context/layout" import { ModelsProvider } from "@/context/models" import { NotificationProvider } from "@/context/notification" import { PermissionProvider } from "@/context/permission" -import { usePlatform } from "@/context/platform" import { PromptProvider } from "@/context/prompt" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { SettingsProvider, useSettings } from "@/context/settings" +import { TerminalProvider } from "@/context/terminal" import { TabsProvider, 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 LegacyLayout from "@/pages/layout" -import NewLayout from "@/pages/layout-new" +import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" -import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route" -import { createSessionLineage } from "@/pages/session/session-lineage" - -import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session" -import { NewHome } from "@/pages/home" -import { LegacyHome } from "@/pages/home/legacy-home" +const HomeRoute = lazy(() => import("@/pages/home")) +const Session = lazy(() => import("@/pages/session")) const NewSession = lazy(() => import("@/pages/new-session")) -const SessionRoute = () => { - const settings = useSettings() - const params = useParams() - const [search] = useSearchParams<{ draftId?: string; prompt?: string }>() - const sdk = useSDK() - const server = useServer() - const tabs = useTabs() +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) + }) - if (params.id && settings.general.newLayoutDesigns()) { - const sessionID = params.id return ( - - {(_) => { - const persisted = tabs.store.filter((item) => item.type === "session") - return - }} - + + + ) - } - - // When the new layout is enabled, the legacy new-session route (/:dir/session with no id) - // is replaced by a draft at /new-session?draftId=โ€ฆ - createEffect(() => { - if (!settings.general.newLayoutDesigns()) return - if (params.id || search.draftId) return - if (!tabs.ready() || !sdk().directory) return - tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt) - }) - - 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 = () => ( - - - + }, + { preload: Session.preload }, ) -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) { +// server via ServerKey, then provide the server-scoped shell (Permission/Layout/ +// Notification/Models + the visual Layout) for that server. +function SelectedServerLayout(props: ParentProps) { return ( - {props.children} + + {props.children} + ) } -function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) { +// Wraps /new-session. It resolves the draft's target server and provides the +// server-scoped shell for that server โ€” without ServerKey, so the page never depends +// on the globally "selected" server. +function DraftServerLayout(props: ParentProps) { + const server = useServer() + const tabs = useTabs() + const [search] = useSearchParams<{ draftId?: string }>() + const conn = createMemo(() => { + const id = search.draftId + if (!id) return undefined + const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id) + if (!draft) return undefined + return server.list.find((c) => ServerConnection.key(c) === draft.server) + }) + return ( - - {props.children} - + + + {props.children} + + ) } function DraftRoute() { const [search] = useSearchParams<{ draftId?: string }>() - const settings = useSettings() const tabs = useTabs() return ( - tab.type === "draft" && tab.draftID === search.draftId)} - keyed - fallback={} - > - {(draft) => ( - } - > - - - )} + }> + {(draftID) => } ) } -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 +function ResolvedDraftRoute(props: { draftID: string }) { + const tabs = useTabs() + const draft = createMemo(() => + tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID), + ) + + // Key on the directory so retargeting the draft's project re-instantiates the + // directory-scoped providers while keeping the same draft id. The draft's target + // server is provided by DraftServerLayout, so changing only the server updates the + // SDK/sync hooks without remounting the composer. + const directory = () => draft()?.directory return ( - - - - - - - - - - - - - - + + {(dir) => ( + + + + + + + + )} ) } @@ -237,37 +165,13 @@ 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"; scheme?: "system" | "light" | "dark" }) => Promise + setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise exportDebugLogs?: () => Promise } } @@ -289,11 +193,10 @@ function QueryProvider(props: ParentProps) { function BodyDesignClass() { const settings = useSettings() - createRenderEffect(() => { + createEffect(() => { 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) @@ -307,69 +210,41 @@ function BodyDesignClass() { // shell (router root) so they stay mounted regardless of the active server/route. function SharedProviders(props: ParentProps) { return ( - <> + - {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) { +// Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside +// each per-route server layout so they resolve to that route's server (selected vs +// draft). The Layout remounts when crossing between those groups. +function ServerScopedShell(props: ParentProps) { return ( - - {props.serverScoped} - {props.children} - + + + + + {props.children} + + + + ) } -function LegacyServerScopedShell(props: ServerScopedShellProps) { +function SessionProviders(props: ParentProps) { return ( - - {props.children} - - ) -} - -function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) { - return ( - - - {props.children} - - + + + + {props.children} + + + ) } @@ -390,8 +265,8 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { { - void window.api?.setTitlebar?.({ mode, scheme }) + onThemeApplied={(_, mode) => { + void window.api?.setTitlebar?.({ mode }) }} > @@ -419,7 +294,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { ) } -function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; startup?: Promise }>) { +function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { const server = useServer() const checkServerHealth = useCheckServerHealth() @@ -448,45 +323,34 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; start const checking = createMemo( () => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state), ) - const [startup] = createResource(async () => { - if (!props.startup) return true - await props.startup.catch((error) => { - console.error("[startup] startup gate failed", error) - }) - return true - }) - const startupChecking = createMemo( - () => startupHealthCheck.latest === true && ["unresolved", "pending"].includes(startup.state), - ) - const loading = createMemo(() => checking() || startupChecking()) return ( - <> - - { - if (checkMode() === "background") void healthCheckActions.refetch() - }} - onServerSelected={(key) => { - setCheckMode("blocking") - server.setActive(key) - void healthCheckActions.refetch() - }} - /> - } - > - {props.children} - - - -
+
+ } + > + { + if (checkMode() === "background") void healthCheckActions.refetch() + }} + onServerSelected={(key) => { + setCheckMode("blocking") + server.setActive(key) + void healthCheckActions.refetch() + }} + /> + } + > + {props.children} - +
) } @@ -553,12 +417,12 @@ 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. + // The shared shell holds only server-agnostic providers (QueryClient + Settings/ + // Command/Highlights) and stays mounted across every route. The server-scoped + // providers and the visual Layout live in the per-route layouts below, so they + // resolve to that route's server (selected for most routes, the draft's server for + // /new-session). appChildren is server-agnostic, so it renders here once. const ServerShell = (shellProps: ParentProps) => ( @@ -575,85 +439,28 @@ export function AppInterface(props: { servers={props.servers} > - - - - ( - - - - - - {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 deleted file mode 100644 index aeca6d977a..0000000000 Binary files a/packages/app/src/assets/help/home.png and /dev/null differ diff --git a/packages/app/src/assets/help/introducing-tabs.mp4 b/packages/app/src/assets/help/introducing-tabs.mp4 deleted file mode 100644 index fd46be1f46..0000000000 Binary files a/packages/app/src/assets/help/introducing-tabs.mp4 and /dev/null differ diff --git a/packages/app/src/assets/help/placeholder.png b/packages/app/src/assets/help/placeholder.png deleted file mode 100644 index c651d06cc9..0000000000 Binary files a/packages/app/src/assets/help/placeholder.png and /dev/null differ diff --git a/packages/app/src/assets/help/tabs.png b/packages/app/src/assets/help/tabs.png deleted file mode 100644 index d2c6e68a3d..0000000000 Binary files a/packages/app/src/assets/help/tabs.png and /dev/null differ diff --git a/packages/app/src/components/command-palette.ts b/packages/app/src/components/command-palette.ts deleted file mode 100644 index 8014ea1c43..0000000000 --- a/packages/app/src/components/command-palette.ts +++ /dev/null @@ -1,281 +0,0 @@ -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 deleted file mode 100644 index 63c7f32468..0000000000 --- a/packages/app/src/components/command-tooltip-keybind.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index d6685b95a8..0000000000 --- a/packages/app/src/components/command-tooltip-keybind.ts +++ /dev/null @@ -1,11 +0,0 @@ -type CommandKeybind = { - keybindParts: (id: string) => string[] -} - -export function reviewTooltipKeybind(command: CommandKeybind, _translate?: (key: string) => string) { - return command.keybindParts("review.toggle") -} - -export function newTabTooltipKeybind(command: CommandKeybind, _translate?: (key: string) => string) { - return command.keybindParts("tab.new") -} diff --git a/packages/app/src/components/debug-bar.tsx b/packages/app/src/components/debug-bar.tsx index adadeda303..11f9f59e4e 100644 --- a/packages/app/src/components/debug-bar.tsx +++ b/packages/app/src/components/debug-bar.tsx @@ -3,9 +3,7 @@ import { batch, createEffect, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { makeEventListener } from "@solid-primitives/event-listener" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { useLanguage } from "@/context/language" -import { usePlatform } from "@/context/platform" type Mem = Performance & { memory?: { @@ -53,44 +51,19 @@ const bad = (n: number | undefined, limit: number, low = false) => { const session = (path: string) => path.includes("/session") -function Cell(props: { - bad?: boolean - dim?: boolean - inline?: boolean - label: string - tip: string - value: string - wide?: boolean -}) { - const content = () => ( -
+function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string; wide?: boolean }) { + return ( +
+
{props.label}
- {props.label} -
-
-
- ) - - if (props.inline) { - return ( - - {content()} - - ) - } - - return ( - - {content()} ) } -function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) { - const content = () => ( - - ) - - if (props.inline) { - return ( - - {content()} - - ) - } - - return ( - - {content()} - - ) -} - -export function DebugBar(props: { inline?: boolean } = {}) { +export function DebugBar() { const language = useLanguage() - const platform = usePlatform() const location = useLocation() const routing = useIsRouting() const [state, setState] = createStore({ @@ -172,7 +84,6 @@ export function DebugBar(props: { inline?: boolean } = {}) { delay: undefined as number | undefined, fps: undefined as number | undefined, gap: undefined as number | undefined, - focus: false, heap: { limit: undefined as number | undefined, used: undefined as number | undefined, @@ -190,7 +101,7 @@ export function DebugBar(props: { inline?: boolean } = {}) { }, }) - const na = () => language.t("debugBar.na").toUpperCase() + const na = () => language.t("debugBar.na") const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined) const heapv = () => { const value = heap() @@ -199,16 +110,6 @@ export function DebugBar(props: { inline?: boolean } = {}) { } const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`) const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na())) - const toggleFocus = async () => { - if (!platform.setForceFocus) return - const enabled = !state.focus - await platform.setForceFocus(enabled) - setState("focus", enabled) - } - - onCleanup(() => { - if (state.focus) void platform.setForceFocus?.(false).catch(() => undefined) - }) let prev = "" let start = 0 @@ -462,30 +363,15 @@ export function DebugBar(props: { inline?: boolean } = {}) { return ( ) diff --git a/packages/app/src/components/dialog-command-palette-v2.css b/packages/app/src/components/dialog-command-palette-v2.css deleted file mode 100644 index a4019928aa..0000000000 --- a/packages/app/src/components/dialog-command-palette-v2.css +++ /dev/null @@ -1,220 +0,0 @@ -.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 deleted file mode 100644 index e996fd0be7..0000000000 --- a/packages/app/src/components/dialog-command-palette-v2.tsx +++ /dev/null @@ -1,342 +0,0 @@ -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 deleted file mode 100644 index 3aec6c9de3..0000000000 --- a/packages/app/src/components/dialog-connect-provider.stories.tsx +++ /dev/null @@ -1,73 +0,0 @@ -// @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 0267b4b4ef..76da7d5b42 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 { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise" +import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -7,392 +7,27 @@ 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 { - type Accessor, - type Component, - createEffect, - createMemo, - createResource, - createUniqueId, - For, - Match, - onCleanup, - onMount, - Show, - Switch, -} from "solid-js" +import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" -import { useParams } from "@solidjs/router" -import { ExternalLink } from "@/components/external-link" +import { Link } from "@/components/link" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" -import { useSettings } from "@/context/settings" -import { popularProviders, useProviders } from "@/hooks/use-providers" -import { CustomProviderForm } from "./dialog-custom-provider" -import { decode64 } from "@/utils/base64" +import { useProviders } from "@/hooks/use-providers" -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 -}) { +export function DialogConnectProvider(props: { provider: string }) { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() - const params = useParams() const language = useLanguage() - 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 providers = useProviders() + + const all = () => { + void import("./dialog-select-provider").then((x) => { + dialog.show(() => ) + }) } const alive = { value: true } @@ -408,33 +43,28 @@ function ProviderConnection(props: { const provider = createMemo( () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!, ) - const fallback = createMemo(() => [ + const fallback = createMemo(() => [ { - type: "key" as const, + type: "api" as const, label: language.t("provider.connect.method.apiKey"), }, ]) - 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 [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 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 loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider]) + const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()) const [store, setStore] = createStore({ methodIndex: undefined as undefined | number, - authorization: undefined as undefined | IntegrationOauthConnectOutput["data"], - promptInputs: undefined as undefined | Record, + authorization: undefined as undefined | ProviderAuthAuthorization, state: "pending" as undefined | "pending" | "complete" | "error" | "prompt", error: undefined as string | undefined, }) @@ -443,9 +73,8 @@ function ProviderConnection(props: { | { type: "method.select"; index: number } | { type: "method.reset" } | { type: "auth.prompt" } - | { type: "auth.inputs"; inputs: Record } | { type: "auth.pending" } - | { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] } + | { type: "auth.complete"; authorization: ProviderAuthAuthorization } | { type: "auth.error"; error: string } function dispatch(action: Action) { @@ -454,7 +83,6 @@ function ProviderConnection(props: { if (action.type === "method.select") { draft.methodIndex = action.index draft.authorization = undefined - draft.promptInputs = undefined draft.state = undefined draft.error = undefined return @@ -462,7 +90,6 @@ function ProviderConnection(props: { if (action.type === "method.reset") { draft.methodIndex = undefined draft.authorization = undefined - draft.promptInputs = undefined draft.state = undefined draft.error = undefined return @@ -472,12 +99,6 @@ function ProviderConnection(props: { 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 @@ -499,20 +120,10 @@ function ProviderConnection(props: { const methodLabel = (value?: { type?: string; label?: string }) => { if (!value) return "" - if (value.type === "key") return language.t("provider.connect.method.apiKey") + if (value.type === "api") 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 @@ -546,16 +157,31 @@ function ProviderConnection(props: { return } dispatch({ type: "auth.pending" }) + const start = Date.now() await serverSDK() - .api.integration.oauth.connect({ - integrationID: props.provider, - methodID: method.id, - inputs: inputs ?? {}, - location: location(), - }) + .client.provider.oauth.authorize( + { + providerID: props.provider, + method: index, + inputs, + }, + { throwOnError: true }, + ) .then((x) => { if (!alive.value) return - dispatch({ type: "auth.complete", authorization: x.data }) + 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! }) }) .catch((e) => { if (!alive.value) return @@ -564,15 +190,16 @@ function ProviderConnection(props: { } } - function AuthPromptsView() { + function OAuthPromptsView() { const [formStore, setFormStore] = createStore({ value: {} as Record, index: 0, }) - const prompts = createMemo(() => { + const prompts = createMemo>(() => { const value = method() - return value?.type === "oauth" ? (value.prompts ?? []) : [] + if (value?.type !== "oauth") return [] + return value.prompts ?? [] }) const matches = (prompt: NonNullable[number]>, value: Record) => { if (!prompt.when) return true @@ -704,9 +331,7 @@ function ProviderConnection(props: { }) async function complete() { - await serverSync() - .refreshProviders() - .catch(() => undefined) + await serverSDK().client.global.dispose() dialog.close() showToast({ variant: "success", @@ -717,47 +342,22 @@ function ProviderConnection(props: { } function goBack() { - if (methods().length > 1 && store.methodIndex !== undefined) { + if (methods().length === 1) { + all() + return + } + if (store.authorization) { dispatch({ type: "method.reset" }) return } - props.onBack() + if (store.methodIndex !== undefined) { + dispatch({ type: "method.reset" }) + return + } + all() } - props.setBack(goBack) - function MethodSelection() { - if (newLayout()) - return ( -
-
- {language.t("provider.connect.selectMethod", { provider: provider().name })} -
-
- - {(item, index) => { - const details = () => methodDetails(item) - return ( - - ) - }} - -
-
- ) - return ( <>
@@ -770,7 +370,7 @@ function ProviderConnection(props: { listRef = ref }} items={methods} - key={(m) => m?.label ?? m?.type} + key={(m) => m?.label} onSelect={async (selected, index) => { if (!selected) return void selectMethod(index) @@ -791,18 +391,11 @@ function ProviderConnection(props: { } 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() @@ -816,67 +409,16 @@ function ProviderConnection(props: { } setFormStore("error", undefined) - await serverSDK().api.integration.connect.key({ - integrationID: props.provider, - location: location(), - key: apiKey, + await serverSDK().client.auth.set({ + providerID: props.provider, + auth: { + type: "api", + 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 (
@@ -886,9 +428,9 @@ function ProviderConnection(props: {
{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")}
@@ -901,8 +443,7 @@ function ProviderConnection(props: {
{ - if (!newLayout()) return - codeInput?.focus({ preventScroll: true }) - }) - async function handleSubmit(e: SubmitEvent) { e.preventDefault() @@ -947,13 +481,12 @@ function ProviderConnection(props: { setFormStore("error", undefined) const result = await serverSDK() - .api.integration.oauth.complete({ - integrationID: props.provider, - attemptID: store.authorization!.attemptID, - location: location(), + .client.provider.oauth.callback({ + providerID: props.provider, + method: store.methodIndex, code, }) - .then(() => ({ ok: true as const })) + .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) .catch((error) => ({ ok: false as const, error })) if (result.ok) { await complete() @@ -962,59 +495,16 @@ function ProviderConnection(props: { 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 })}
{ - const poll = async () => { - const authorization = store.authorization - if (!authorization || !alive.value) return + void (async () => { const result = await serverSDK() - .api.integration.oauth.status({ - integrationID: props.provider, - attemptID: authorization.attemptID, - location: location(), + .client.provider.oauth.callback({ + providerID: props.provider, + method: store.methodIndex, }) - .then((value) => ({ ok: true as const, status: value.data })) + .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) .catch((error) => ({ ok: false as const, error })) + if (!alive.value) return + if (!result.ok) { - dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) }) + const message = formatError(result.error, language.t("common.requestFailed")) + dispatch({ type: "auth.error", error: message }) return } - 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() + + await complete() + })() }) 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.failed", { error: store.error ?? "" })} +
+
+
+ + + + + + + + + + + + + +
+
-
-
- - -
-
- - {language.t("provider.connect.status.inProgress")} -
-
-
- - - - -
-
- - {language.t("provider.connect.status.inProgress")} -
-
-
- - - - -
-
- - {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 e34e4c39b8..f2b0432dde 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -8,39 +8,18 @@ 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 { ExternalLink } from "@/components/external-link" +import { Link } from "@/components/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 = { - onBack: () => void + back?: "providers" | "close" } 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() @@ -56,6 +35,14 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) { err: {}, }) + const goBack = () => { + if (props.back === "close") { + dialog.close() + return + } + dialog.show(() => ) + } + const addModel = () => { setForm( "models", @@ -131,7 +118,6 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) { const saveMutation = useMutation(() => ({ mutationFn: async (result: NonNullable>) => { - 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) @@ -176,155 +162,168 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) { } return ( -
-
- -
{language.t("provider.custom.title")}
+ + } + transition + > +
+
+ +
{language.t("provider.custom.title")}
+
+ + +

+ {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)} + /> +
+ +
+ + + {(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")} + /> +
+ )} +
+ +
+ +
+ + + {(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")} + /> +
+ )} +
+ +
+ + +
- -
-

- {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)} - /> -
- -
- - - {(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")} - /> -
- )} -
- -
- -
- - - {(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")} - /> -
- )} -
- -
- - -
-
+ ) } diff --git a/packages/app/src/components/dialog-edit-project-v2.tsx b/packages/app/src/components/dialog-edit-project-v2.tsx deleted file mode 100644 index dc9714aa06..0000000000 --- a/packages/app/src/components/dialog-edit-project-v2.tsx +++ /dev/null @@ -1,156 +0,0 @@ -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 86a9630359..29ee4dfeab 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -1,32 +1,123 @@ 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 { For, Show } from "solid-js" +import { createMemo, For, Show } from "solid-js" +import { createStore } from "solid-js/store" 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 { createEditProjectModel } from "./edit-project" +import { useGlobal } from "@/context/global" 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 model = createEditProjectModel(props) + 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() + } return ( -
+
model.setStore("name", v)} + placeholder={folderName()} + value={store.name} + onChange={(v) => setStore("name", v)} />
@@ -34,32 +125,38 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
model.setStore("iconHover", true)} - onMouseLeave={() => model.setStore("iconHover", false)} + onMouseEnter={() => setStore("iconHover", true)} + onMouseLeave={() => setStore("iconHover", false)} >
{ + if (store.iconOverride && store.iconHover) { + clearIcon() + } else { + iconInput?.click() + } }} - onDrop={model.drop} - onDragOver={model.dragOver} - onDragLeave={model.dragLeave} - onClick={model.iconClick} >
@@ -77,8 +174,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
@@ -86,8 +183,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
@@ -96,12 +193,12 @@ export function DialogEditProject(props: { project: LocalProject; server: Server { - model.setIconInput(el) + iconInput = el }} type="file" accept="image/*" class="hidden" - onChange={model.inputChange} + onChange={handleInputChange} />
{language.t("dialog.project.edit.icon.hint")} @@ -110,7 +207,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
- +
@@ -119,21 +216,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 5187d980ea..601f03084c 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -69,11 +69,15 @@ export const DialogFork: Component = () => { const dir = base64Encode(sdk().directory) sdk() - .api.session.fork({ sessionID, messageID: item.id }) + .client.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.id }) - navigate(`/${dir}/session/${forked.id}`) + prompt.set(restored, undefined, { dir, id: forked.data.id }) + navigate(`/${dir}/session/${forked.data.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 d6c5e99186..b46034bbad 100644 --- a/packages/app/src/components/dialog-manage-models.tsx +++ b/packages/app/src/components/dialog-manage-models.tsx @@ -3,35 +3,20 @@ 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 { 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 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 { 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] +import { DialogSelectProvider } from "./dialog-select-provider" export const DialogManageModels: Component = () => { const local = useLocal() const language = useLanguage() const dialog = useDialog() - const directory = () => decode64(local.slug()) const handleConnectProvider = () => { - void dialog.show(() => ) + dialog.show(() => ) } const providerRank = (id: string) => popularProviders.indexOf(id) const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) @@ -115,151 +100,3 @@ 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.tsx b/packages/app/src/components/dialog-select-directory-v2.tsx index f3376ad35f..3971ac2578 100644 --- a/packages/app/src/components/dialog-select-directory-v2.tsx +++ b/packages/app/src/components/dialog-select-directory-v2.tsx @@ -1,6 +1,6 @@ import "@pierre/trees/web-components" import { FileTree } from "@pierre/trees" -import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2" +import { Dialog, DialogFooter } 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" @@ -8,7 +8,6 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup, 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, @@ -20,7 +19,6 @@ import { pickerMode, preloadTreeDirectories, cleanPickerInput, - createPriorityTaskQueue, createDirectorySearch, currentPickerSuggestions, displayPickerPath, @@ -28,8 +26,6 @@ import { 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 @@ -42,7 +38,7 @@ interface DialogSelectDirectoryV2Props { export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { const global = useGlobal() - const { sync, sdk } = global.ensureServerCtx(props.server) + const { sync, sdk } = global.createServerCtx(props.server) const dialog = useDialog() const language = useLanguage() const policy = pickerMode(props.mode ?? "directory", props.start) @@ -59,7 +55,6 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { 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 @@ -69,13 +64,11 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { 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 + () => + sdk.client.path .get() .then((result) => result.data) - .catch(() => undefined) - }, + .catch(() => undefined), { initialValue: undefined }, ) const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") @@ -89,26 +82,18 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { ) const search = createDirectorySearch({ sdk, home, base: () => root() || start() }) const [suggestions] = createResource(input, async (value) => { - const cleaned = cleanPickerInput(value) - const typed = cleaned.replace(/\/+$/, "") + const typed = cleanPickerInput(value).replace(/\/+$/, "") const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "") - if (!cleaned || (root() && typed === current)) return { query: value, items: [] } + if (!typed || 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) + const files = await sdk.client.find + .files({ directory: root(), query: pickerFileSearchQuery(root(), 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 })), + ...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })), ] return { query: value, @@ -117,26 +102,16 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { }) const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input())) - async function load(path: string, generation: number, eager = false) { + async function load(path: string, generation: number, preload = true) { 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.get(key) ?? + sdk.client.file + .list({ directory: absolute, path: "" }) + .then((result) => result.data ?? []) + .catch(() => undefined) listings.set(key, request) const nodes = await request if (!activeTreeNavigation(generation, navigation)) return false @@ -146,8 +121,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { 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) + if (preload && advanceTreePreload(advanced, key)) { + void Promise.all(preloadTreeDirectories(key, nodes).map((directory) => load(directory, generation, false))) } return true } @@ -284,12 +259,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { onCleanup(() => tree?.cleanUp()) return ( - - - {props.title ?? language.t("command.project.open")} - - - + +
(
{policy.result(root(), selected(), rootValid())}
- +
dialog.close()}> {language.t("common.cancel")} diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index fc248d821e..7dd1acc056 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -9,7 +9,6 @@ import { useLanguage } from "@/context/language" import { ServerConnection } from "@/context/server" import { useGlobal } from "@/context/global" import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain" -import type { Path } from "@opencode-ai/sdk/v2/client" interface DialogSelectDirectoryProps { title?: string @@ -18,8 +17,6 @@ interface DialogSelectDirectoryProps { server: ServerConnection.Any } -const RECENT_PROJECT_LIMIT = 5 - type Row = { absolute: string search: string @@ -52,21 +49,20 @@ function uniqueRows(rows: Row[]) { export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const global = useGlobal() - const { sync, sdk, ...serverCtx } = global.ensureServerCtx(props.server) + const { sync, sdk, ...serverCtx } = global.createServerCtx(props.server) const dialog = useDialog() const language = useLanguage() const [filter, setFilter] = createSignal("") let list: ListRef | undefined - const missingHome = createMemo(() => !sync.data.path.home) + const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) const [fallbackPath] = createResource( - () => (missingHome() ? true : undefined), - async (): Promise => { - if ((await sdk.protocol) !== "v1") return + () => (missingBase() ? true : undefined), + async () => { return sdk.client.path .get() - .then((result) => result.data) + .then((x) => x.data) .catch(() => undefined) }, { initialValue: undefined }, @@ -104,6 +100,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { return projects .map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index })) .sort((a, b) => b.at - a.at || a.index - b.index) + .slice(0, 5) .map(({ project }) => { const row = toRow(project.worktree, home(), "recent") const name = project.name || getFilename(project.worktree) @@ -117,10 +114,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const items = async (value: string) => { const results = await directories(value) const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders")) - // Cap the idle list only. Once a query narrows the results, every project stays searchable. - const recent = recentProjects() - const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT) - return uniqueRows([...visible, ...directoryRows]) + return uniqueRows([...recentProjects(), ...directoryRows]) } function resolve(absolute: string) { @@ -168,7 +162,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const path = displayPickerPath(item.absolute, filter(), home()) if (path === "~") { return ( -
+
@@ -180,7 +174,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 905abe1e2d..444cb37f8a 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -1,81 +1,331 @@ +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 { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js" -import { formatKeybind } from "@/context/command" -import { useServerSDK } from "@/context/server-sdk" +import { useNavigate } from "@solidjs/router" +import { createMemo, createSignal, lazy, Match, onCleanup, Show, Switch } from "solid-js" +import { formatKeybind, useCommand, type CommandOption } from "@/context/command" +import { useServerSDK, type ServerSDK } 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 { useServer } from "@/context/server" 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" -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 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 +} + 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 } +} + +function createSessionEntries(props: { + workspaces: () => string[] + label: (directory: string) => string + serverSDK: ServerSDK + language: ReturnType +}) { + const state: { + token: number + inflight: Promise | undefined + cached: Entry[] | undefined + } = { + token: 0, + inflight: undefined, + cached: undefined, + } + + const sessions = (text: string) => { + const query = text.trim() + if (!query) { + state.token += 1 + state.inflight = undefined + state.cached = undefined + return [] as Entry[] + } + + if (state.cached) return state.cached + if (state.inflight) return state.inflight + + const current = state.token + const dirs = props.workspaces() + if (dirs.length === 0) return [] as Entry[] + + state.inflight = Promise.all( + dirs.map((directory) => { + const description = props.label(directory) + return props.serverSDK.client.session + .list({ directory, roots: true }) + .then((x) => + (x.data ?? []) + .filter((s) => !!s?.id) + .map((s) => ({ + id: s.id, + title: s.title ?? props.language.t("command.session.new"), + description, + directory, + archived: s.time?.archived, + updated: s.time?.updated, + })), + ) + .catch( + () => + [] as { + id: string + title: string + description: string + directory: string + archived?: number + updated?: number + }[], + ) + }), + ) + .then((results) => { + if (state.token !== current) return [] as Entry[] + const seen = new Set() + const category = props.language.t("command.category.session") + const next = results + .flat() + .filter((item) => { + const key = `${item.directory}:${item.id}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + .map((item) => createSessionEntry(item, category)) + state.cached = next + return next + }) + .catch(() => [] as Entry[]) + .finally(() => { + state.inflight = undefined + }) + + return state.inflight + } + + return { sessions } +} + export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { - const platform = usePlatform() - const settings = useSettings() - const filesOnly = () => props.mode === "files" - - if (!filesOnly() && settings.general.newLayoutDesigns()) { - return - } - - if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) { - return - } - - return -} - -function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void }) { + const command = useCommand() const language = useLanguage() + const platform = usePlatform() + const server = useServer() + const settings = useSettings() + const layout = useLayout() + const file = useFile() + const dialog = useDialog() + const navigate = useNavigate() const serverSDK = useServerSDK() - const { params } = useSessionLayout() - const projectDirectory = createMemo(() => decode64(params.dir) ?? "") - const openFile = createCommandPaletteFileOpener(props.onOpenFile) - - return ( - { - if (typeof result !== "string") return - openFile(result) - }} - /> - ) -} - -function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?: (path: string) => void }) { - const palette = createCommandPaletteModel(props) + 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 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 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}` + } + + const { sessions } = createSessionEntries({ workspaces, label, serverSDK: serverSDK(), language }) const items = async (text: string) => { const query = text.trim() setGrouped(query.length > 0) - 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 (!query && filesOnly()) { + const loaded = file.tree.state("")?.loaded + const pending = loaded ? Promise.resolve() : file.tree.list("") + const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()]) if (loaded || next.length > 0) { void pending @@ -83,24 +333,79 @@ function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?: } await pending - return uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()]) + return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()]) } - if (!query) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()] + if (!query) return [...commandEntries.picks(), ...fileEntries.recent()] - 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)) + if (filesOnly()) { + const files = await file.searchFiles(query) + const category = language.t("palette.group.files") + return files.map((path) => createFileEntry(path, category)) } - 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 [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 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?.() + }) + + if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns() && server.current) { + return ( + { + if (typeof result !== "string") return + open(result) + }} + /> + ) } return ( @@ -108,21 +413,21 @@ function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?: item.id} filterKeys={["title", "description", "category"]} skipFilter={(item) => item.type === "file"} groupBy={grouped() ? (item) => item.category : () => ""} - onMove={(item: CommandPaletteEntry | undefined) => palette.highlight(item)} - onSelect={(item: CommandPaletteEntry | undefined) => palette.select(item)} + onMove={handleMove} + onSelect={handleSelect} > {(item) => ( boolean; onOpenFile?:
- {formatKeybind(item.keybind ?? "", palette.language.t)} + {formatKeybind(item.keybind ?? "", language.t)}
@@ -176,7 +481,7 @@ function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?:
- {getRelativeTime(new Date(item.updated!).toISOString(), palette.language.t)} + {getRelativeTime(new Date(item.updated!).toISOString(), language.t)}
diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index 4f1a3cd239..05253381f0 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -43,7 +43,7 @@ export const DialogSelectMcp: Component = () => { filterKeys={["name", "status"]} sortBy={(a, b) => a.name.localeCompare(b.name)} onSelect={(x) => { - if (!x || x.status === "pending" || toggle.isPending) return + if (!x || toggle.isPending) return toggle.mutate(x.name) }} > @@ -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 deleted file mode 100644 index 3ee8009140..0000000000 --- a/packages/app/src/components/dialog-select-model-search.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 901f3fc114..0000000000 --- a/packages/app/src/components/dialog-select-model-search.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index bd4beb2ec9..0000000000 --- a/packages/app/src/components/dialog-select-model-unpaid-v2.stories.tsx +++ /dev/null @@ -1,55 +0,0 @@ -// @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 deleted file mode 100644 index 2d0c3a7305..0000000000 --- a/packages/app/src/components/dialog-select-model-unpaid-v2.tsx +++ /dev/null @@ -1,176 +0,0 @@ -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 4611a36c95..fae743bb81 100644 --- a/packages/app/src/components/dialog-select-model-unpaid.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid.tsx @@ -10,28 +10,26 @@ 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 local = useLocal() - const model = props.model ?? local.model + const model = props.model ?? useLocal().model const dialog = useDialog() - const directory = () => decode64(local.slug()) - const providers = useProviders(directory) + const providers = useProviders() const language = useLanguage() - const openProviders = (provider?: string) => { + const connect = (provider: string) => { void import("./dialog-connect-provider").then((x) => { - const controller = x.useProviderConnectController() - controller.select(provider) - void dialog.show(() => ) + dialog.show(() => ) }) } - const connect = (provider: string) => openProviders(provider) - const all = () => openProviders() + const all = () => { + void import("./dialog-select-provider").then((x) => { + dialog.show(() => ) + }) + } 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 9066f72434..ca2643c3b5 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -1,48 +1,22 @@ import { Popover as Kobalte } from "@kobalte/core/popover" -import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js" +import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } 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 @@ -84,7 +58,6 @@ const ModelList: Component<{ class="w-full" placement="right-start" gutter={12} - openDelay={0} value={} > {node} @@ -113,13 +86,14 @@ 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 - trigger: ModelSelectorTrigger + children?: JSX.Element + triggerAs?: ValidComponent + triggerProps?: ModelSelectorTriggerProps onClose?: (cause: "escape" | "select") => void }) { const [store, setStore] = createStore<{ @@ -130,8 +104,6 @@ export function ModelSelectorPopover(props: { dismiss: null, }) const dialog = useDialog() - const local = useLocal() - const directory = () => decode64(local.slug()) const close = (dismiss: Dismiss) => { setStore("dismiss", dismiss) @@ -147,8 +119,8 @@ export function ModelSelectorPopover(props: { const handleConnectProvider = () => { close("provider") - void import("./dialog-connect-provider").then((x) => { - void dialog.show(() => ) + void import("./dialog-select-provider").then((x) => { + dialog.show(() => ) }) } const language = useLanguage() @@ -164,7 +136,9 @@ 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-connect-provider").then((x) => { - void dialog.show(() => ) + void import("./dialog-select-provider").then((x) => { + dialog.show(() => ) }) } diff --git a/packages/app/src/components/dialog-select-provider.tsx b/packages/app/src/components/dialog-select-provider.tsx new file mode 100644 index 0000000000..89310286da --- /dev/null +++ b/packages/app/src/components/dialog-select-provider.tsx @@ -0,0 +1,87 @@ +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 aa16976228..0876906890 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -16,7 +16,6 @@ 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" @@ -264,13 +263,6 @@ 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) { @@ -315,13 +307,6 @@ 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 { @@ -359,10 +344,7 @@ export function useServerManagementController(options: { onSelect?: () => void; ) const sortedItems = createMemo(() => { - const raw = items() - const list = settings.general.newLayoutDesigns() - ? raw - : raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2") + const list = items() 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 b554fa79a8..20d71f4bfd 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -1,35 +1,22 @@ -import { Component, createSignal, startTransition } from "solid-js" +import { Component } 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<{ defaultValue?: string }> = (props) => { +export const DialogSettings: Component = () => { 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" - > +
@@ -83,7 +70,7 @@ export const DialogSettings: Component<{ defaultValue?: string }> = (props) => { - + diff --git a/packages/app/src/components/dialog-usage-exceeded.tsx b/packages/app/src/components/dialog-usage-exceeded.tsx index bf5da751e2..e428d4c2bb 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.openExternal(props.link) + if (props.link) platform.openLink(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 index 1bc9af0833..d28344c935 100644 --- a/packages/app/src/components/directory-picker-domain.test.ts +++ b/packages/app/src/components/directory-picker-domain.test.ts @@ -15,7 +15,6 @@ import { treePathWithin, currentPickerSuggestions, createDirectorySearch, - createPriorityTaskQueue, displayPickerPath, pickerParent, pickerRoot, @@ -133,10 +132,10 @@ test("scopes file autocomplete to the current browser root", () => { 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 ?? "") + client: { + find: { + files: (input: { directory: string }) => { + directories.push(input.directory) return Promise.resolve({ data: [] }) }, }, @@ -152,29 +151,6 @@ test("resolves directory autocomplete from the current browser root", async () = 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/", [ @@ -192,41 +168,6 @@ test("advances preloading once for every expanded directory", () => { 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) diff --git a/packages/app/src/components/directory-picker-domain.ts b/packages/app/src/components/directory-picker-domain.ts index 9539ae1d01..2faa8cb2e0 100644 --- a/packages/app/src/components/directory-picker-domain.ts +++ b/packages/app/src/components/directory-picker-domain.ts @@ -138,79 +138,6 @@ 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)) } @@ -326,15 +253,15 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string 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 + const raw = normalizePickerDrive(value) 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) } + const root = pickerRoot(raw) + if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) } return { directory: trimPickerPath(base), path: raw } } @@ -342,17 +269,14 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => 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) + const request = args.sdk.client.file + .list({ directory: key, path: "" }) + .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) } - }), + .map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })), ) cache.set(key, request) return request @@ -374,9 +298,9 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string 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)) + const results = await args.sdk.client.find + .files({ directory: input.directory, query, type: "directory", limit: 50 }) + .then((result) => result.data ?? []) .catch(() => []) if (!active()) return [] return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50) diff --git a/packages/app/src/components/edit-project.ts b/packages/app/src/components/edit-project.ts deleted file mode 100644 index 0c5d576e93..0000000000 --- a/packages/app/src/components/edit-project.ts +++ /dev/null @@ -1,133 +0,0 @@ -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 deleted file mode 100644 index 133e752eab..0000000000 --- a/packages/app/src/components/external-link.tsx +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index f2a63fe86d..0000000000 --- a/packages/app/src/components/file-tree-v2-model.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index 27783a9bb1..0000000000 --- a/packages/app/src/components/file-tree-v2-model.ts +++ /dev/null @@ -1,107 +0,0 @@ -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 deleted file mode 100644 index 26cf49d411..0000000000 --- a/packages/app/src/components/file-tree-v2.tsx +++ /dev/null @@ -1,299 +0,0 @@ -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 718e2f3493..211ce05ef0 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 -export function pathToFileUrl(filepath: string): string { +function pathToFileUrl(filepath: string): string { return `file://${encodeFilePath(filepath)}` } -export type Kind = "add" | "del" | "mix" +type Kind = "add" | "del" | "mix" -export type Filter = { +type Filter = { files: Set dirs: Set } @@ -78,7 +78,7 @@ const kindDotColor = (kind: Kind) => { return "background-color: var(--icon-diff-modified-base)" } -export const visibleKind = (node: FileNode, kinds?: ReadonlyMap, marks?: Set) => { +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 } -export const withFileDragImage = (event: DragEvent) => { +const withFileDragImage = (event: DragEvent) => { const image = buildDragImage(event.currentTarget as HTMLElement) if (!image) return document.body.appendChild(image) @@ -201,7 +201,6 @@ export default function FileTree(props: { kinds?: ReadonlyMap draggable?: boolean onFileClick?: (file: FileNode) => void - onFileDoubleClick?: (file: FileNode) => void _filter?: Filter _marks?: Set @@ -441,7 +440,6 @@ export default function FileTree(props: { active={props.active} draggable={props.draggable} onFileClick={props.onFileClick} - onFileDoubleClick={props.onFileDoubleClick} _filter={filter()} _marks={marks()} _deeps={deeps()} @@ -464,7 +462,6 @@ 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 18dd727084..73d623fc6a 100644 --- a/packages/app/src/components/help-button.tsx +++ b/packages/app/src/components/help-button.tsx @@ -1,148 +1,54 @@ -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { Popover } from "@opencode-ai/ui/popover" import { createSignal, Show } from "solid-js" -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" +import { createStore } from "solid-js/store" -// TODO: wire to changelog / seen-state when available -const showPopover = () => true +export function HelpButton() { + if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null -// 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" + const [state, setState] = /* persisted(Persist.global("help-button"), */ createStore({ dismissed: false }) /* ) */ + const [shown, setShown] = createSignal(false) return ( - - -
+
+
- -
-
- - - } - class="absolute top-[10px] left-[-36px]" - /> - -
-

- July 14 -

- - } - /> -
-
-

- Introducing Tabs -

-
-

OpenCode Desktop is now built around tabs.

- -

- Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when - you're starting something new, and close it when you're done. -

-

- Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something - memorable if you plan to keep them around. -

-

- You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab. -

- -

When you reopen the app, your tabs are still open.

-

- The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using - the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout - will become permanent in a few weeks. -

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

Prompt Input with open Todo dock

- -
- ), -} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 08c1ee3489..8d40953ff2 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -4,6 +4,8 @@ import { createEffect, on, Component, + splitProps, + For, Show, onCleanup, createMemo, @@ -11,13 +13,16 @@ import { createResource, Switch, Match, + type ComponentProps, type JSX, } from "solid-js" +import { Popover as KobaltePopover } from "@kobalte/core/popover" +import { createStore, type SetStoreFunction, type Store } from "solid-js/store" +import type { useLocal } from "@/context/local" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" import { ContentPart, DEFAULT_PROMPT, - isCommentItem, isPromptEqual, Prompt, usePrompt, @@ -31,22 +36,15 @@ import { useSync } from "@/context/sync" import { useComments } from "@/context/comments" import { Button } from "@opencode-ai/ui/button" import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" -import { Icon } from "@opencode-ai/ui/icon" +import { Icon, type IconProps } from "@opencode-ai/ui/icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" -import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" -import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model" -import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" -import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" +import { ModelSelectorPopover } from "@/components/dialog-select-model" import { useCommand } from "@/context/command" +import { Persist, persisted } from "@/utils/persist" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" @@ -57,34 +55,126 @@ import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files" import { canNavigateHistoryAtCursor, navigatePromptHistory, + prependHistoryEntry, type PromptHistoryComment, type PromptHistoryEntry, + type PromptHistoryStoredEntry, promptLength, } from "./prompt-input/history" -import { - createPersistedPromptInputHistory, - createPromptInputHistory, - type PromptInputHistory, -} from "./prompt-input/history-store" -import { - type PromptInputControls, - type PromptInputProps, - type PromptInputState, - type PromptInputSubmission, -} from "./prompt-input/contracts" -import { createPromptSubmit } from "./prompt-input/submit" +import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit" import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover" import { PromptContextItems } from "./prompt-input/context-items" import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptDragOverlay } from "./prompt-input/drag-overlay" -import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/placeholder" -import { createPromptInputTransientState } from "./prompt-input/transient-state" +import { promptPlaceholder } from "./prompt-input/placeholder" import { showToast } from "@/utils/toast" import { ImagePreview } from "@opencode-ai/ui/image-preview" -import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" +import { pathKey } from "@/utils/path-key" +import { displayName } from "@/pages/layout/helpers" -export { createPromptInputHistory } -export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission } +export type PromptInputState = ReturnType + +export type PromptInputHistory = { + entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[] + add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void +} + +export type PromptInputSubmission = { + abort: () => Promise | void + handleSubmit: (event: Event) => Promise | void +} + +export type PromptInputControls = { + agents: { + available: { name: string; hidden?: boolean; mode: string }[] + options: string[] + current: string + loading: boolean + visible: boolean + select: (name: string | undefined) => void + } + model: { + selection: ReturnType["model"] + paid: boolean + loading: boolean + } + projects: { + available: { name?: string; worktree: string; sandboxes?: string[] }[] + directory: string + select: (worktree: string) => void + add: (title: string) => void + } + session: { + id?: string + tabs: { + active: () => string | undefined + all: () => string[] + open: (tab: string) => void | Promise + setActive: (tab: string) => void + } + reviewPanel: { + opened: () => boolean + open: () => void + } + } + newLayoutDesigns: boolean +} + +export function createPromptInputHistory(): PromptInputHistory { + const [normal, setNormal] = createStore({ entries: [] }) + const [shell, setShell] = createStore({ entries: [] }) + return createPromptInputHistoryStore(normal, setNormal, shell, setShell) +} + +type PromptHistoryState = { entries: PromptHistoryStoredEntry[] } + +function createPromptInputHistoryStore( + normal: Store, + setNormal: SetStoreFunction, + shell: Store, + setShell: SetStoreFunction, +): PromptInputHistory { + return { + entries: (mode) => (mode === "shell" ? shell.entries : normal.entries), + add(prompt, mode, comments) { + const current = mode === "shell" ? shell : normal + const setCurrent = mode === "shell" ? setShell : setNormal + const next = prependHistoryEntry(current.entries, prompt, comments) + if (next === current.entries) return + setCurrent("entries", next) + }, + } +} + +function createPersistedPromptInputHistory() { + const [normal, setNormal] = persisted( + Persist.global("prompt-history", ["prompt-history.v1"]), + createStore({ entries: [] }), + ) + const [shell, setShell] = persisted( + Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]), + createStore({ entries: [] }), + ) + return createPromptInputHistoryStore(normal, setNormal, shell, setShell) +} + +export interface PromptInputProps { + class?: string + variant?: "dock" | "new-session" + state?: PromptInputState + history?: PromptInputHistory + submission?: PromptInputSubmission + controls: PromptInputControls + ref?: (el: HTMLDivElement) => void + newSessionWorktree?: string + onNewSessionWorktreeReset?: () => void + edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] } + onEditLoaded?: () => void + shouldQueue?: () => boolean + onQueue?: (draft: FollowupDraft) => void + onAbort?: () => void + onSubmit?: () => void +} const EXAMPLES = [ "prompt.example.1", @@ -132,8 +222,7 @@ export const PromptInput: Component = (props) => { let fileInputRef: HTMLInputElement | undefined let scrollRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement - let restoreEndOnFocus = true - let savedCursor: number | null = null + let projectSearchRef: HTMLInputElement | undefined const mirror = { input: false } const inset = 56 @@ -257,10 +346,30 @@ export const PromptInput: Component = (props) => { prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), ) - const [store, setStore] = createPromptInputTransientState( - () => prompt.capture(), - Math.floor(Math.random() * EXAMPLES.length), - ) + const [store, setStore] = createStore<{ + popover: "at" | "slash" | null + historyIndex: number + savedPrompt: PromptHistoryEntry | null + placeholder: number + draggingType: "image" | "@mention" | null + mode: "normal" | "shell" + applyingHistory: boolean + variantOpen: boolean + }>({ + popover: null, + historyIndex: -1, + savedPrompt: null as PromptHistoryEntry | null, + placeholder: Math.floor(Math.random() * EXAMPLES.length), + draggingType: null, + mode: "normal", + applyingHistory: false, + variantOpen: false, + }) + const [picker, setPicker] = createStore({ + projectOpen: false, + projectSearch: "", + }) + const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) const motion = (value: number) => ({ opacity: value, @@ -435,7 +544,7 @@ export const PromptInput: Component = (props) => { const setMode = (mode: "normal" | "shell") => { setStore("mode", mode) - setStore({ popover: null, slashMenu: false, slashMenuQuery: "" }) + setStore("popover", null) requestAnimationFrame(() => editorRef?.focus()) } @@ -469,7 +578,7 @@ export const PromptInput: Component = (props) => { }, ]) - const closePopover = () => setStore({ popover: null, slashMenu: false, slashMenuQuery: "" }) + const closePopover = () => setStore("popover", null) const resetHistoryNavigation = (force = false) => { if (!force && (store.historyIndex < 0 || store.applyingHistory)) return @@ -506,23 +615,13 @@ export const PromptInput: Component = (props) => { const restoreFocus = () => { requestAnimationFrame(() => { - const cursor = savedCursor ?? prompt.cursor() ?? promptLength(prompt.current()) + const cursor = prompt.cursor() ?? promptLength(prompt.current()) editorRef.focus() setCursorPosition(editorRef, cursor) queueScroll() }) } - const handleFocus = () => { - if (!restoreEndOnFocus) return - restoreEndOnFocus = false - requestAnimationFrame(() => { - if (document.activeElement !== editorRef) return - setCursorPosition(editorRef, prompt.cursor() ?? promptLength(prompt.current())) - queueScroll() - }) - } - const renderEditorWithCursor = (parts: Prompt) => { const cursor = currentCursor() renderEditor(parts) @@ -543,9 +642,6 @@ export const PromptInput: Component = (props) => { const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 const handleBlur = () => { - const cursor = currentCursor() - savedCursor = cursor - if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor) closePopover() setComposing(false) } @@ -562,89 +658,24 @@ export const PromptInput: Component = (props) => { }) } - const referenceDescription = (reference: ReferenceInfo) => - reference.source.type === "git" ? reference.source.repository : reference.source.path - - const referenceList = createMemo(() => - sync() - .data.reference.filter((reference) => !reference.hidden) - .map( - (reference): AtOption => ({ - type: "reference", - name: reference.name, - path: reference.path, - display: reference.name, - description: reference.description ?? referenceDescription(reference), - }), - ), - ) - const agentList = createMemo(() => props.controls.agents.available .filter((agent) => !agent.hidden && agent.mode !== "primary") .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), ) - const mcpResourceList = createMemo(() => - Object.values(sync().data.mcp_resource).map( - (resource): AtOption => ({ - type: "resource", - name: resource.name, - uri: resource.uri, - client: resource.server, - display: resource.name, - description: resource.description, - mime: resource.mimeType, - }), - ), - ) - const handleAtSelect = (option: AtOption | undefined) => { if (!option) return if (option.type === "agent") { addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 }) - return + } else { + addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) } - if (option.type === "reference") { - addPart({ - type: "file", - path: option.path, - content: "@" + option.name, - start: 0, - end: 0, - mime: "application/x-directory", - filename: option.name, - }) - return - } - if (option.type === "resource") { - addPart({ - type: "file", - path: option.uri, - content: "@" + option.name, - start: 0, - end: 0, - mime: option.mime ?? "text/plain", - filename: option.name, - url: option.uri, - source: { - type: "resource", - text: { value: "@" + option.name, start: 0, end: 0 }, - clientName: option.client, - uri: option.uri, - }, - }) - return - } - addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) } const atKey = (x: AtOption | undefined) => { if (!x) return "" - if (x.type === "agent") return `agent:${x.name}` - if (x.type === "reference") return `reference:${x.name}` - if (x.type === "resource") return `resource:${x.client}:${x.uri}` - return `file:${x.path}` + return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}` } const { @@ -655,36 +686,30 @@ export const PromptInput: Component = (props) => { onKeyDown: atOnKeyDown, } = useFilteredList({ items: async (query) => { - const references = referenceList() const agents = agentList() - const mcpResources = mcpResourceList() const open = recent() const seen = new Set(open) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) - if (!query.trim()) return [...references, ...agents, ...mcpResources, ...pinned] + if (!query.trim()) return [...agents, ...pinned] const paths = await files.searchFilesAndDirectories(query) const fileOptions: AtOption[] = paths .filter((path) => !seen.has(path)) .map((path) => ({ type: "file", path, display: path })) - return [...references, ...agents, ...mcpResources, ...pinned, ...fileOptions] + return [...agents, ...pinned, ...fileOptions] }, key: atKey, filterKeys: ["display"], skipFilter: (item) => item.type === "file" && !item.recent, groupBy: (item) => { - if (item.type === "reference") return "reference" if (item.type === "agent") return "agent" - if (item.type === "resource") return "resource" if (item.recent) return "recent" return "file" }, sortGroupsBy: (a, b) => { const rank = (category: string) => { - if (category === "reference") return 0 - if (category === "agent") return 1 - if (category === "resource") return 2 - if (category === "recent") return 3 - return 4 + if (category === "agent") return 0 + if (category === "recent") return 1 + return 2 } return rank(a.category) - rank(b.category) }, @@ -709,7 +734,7 @@ export const PromptInput: Component = (props) => { title: cmd.name, description: cmd.description, type: "custom" as const, - // source: cmd.source, + source: cmd.source, })) return [...custom, ...builtin] @@ -717,30 +742,17 @@ export const PromptInput: Component = (props) => { const handleSlashSelect = (cmd: SlashCommand | undefined) => { if (!cmd) return - const menu = store.slashMenu closePopover() const images = imageAttachments() if (cmd.type === "custom") { const text = `/${cmd.trigger} ` - if (menu) { - editorRef.focus() - setCursorPosition(editorRef, 0) - addPart({ type: "text", content: text, start: 0, end: text.length }) - focusEditorEnd() - return - } setEditorText(text) prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length) focusEditorEnd() return } - if (menu) { - command.trigger(cmd.id, "slash") - return - } - clearEditor() prompt.set([...DEFAULT_PROMPT, ...images], 0) command.trigger(cmd.id, "slash") @@ -763,17 +775,7 @@ export const PromptInput: Component = (props) => { const pill = document.createElement("span") pill.textContent = part.content pill.setAttribute("data-type", part.type) - if (part.type === "file") { - pill.setAttribute("data-path", part.path) - if (part.mime) pill.setAttribute("data-mime", part.mime) - if (part.filename) pill.setAttribute("data-filename", part.filename) - if (part.url) pill.setAttribute("data-url", part.url) - if (part.source?.type === "resource") { - pill.setAttribute("data-source-type", part.source.type) - pill.setAttribute("data-source-client-name", part.source.clientName) - pill.setAttribute("data-source-uri", part.source.uri) - } - } + if (part.type === "file") pill.setAttribute("data-path", part.path) if (part.type === "agent") pill.setAttribute("data-name", part.name) pill.setAttribute("contenteditable", "false") pill.style.userSelect = "text" @@ -818,7 +820,8 @@ export const PromptInput: Component = (props) => { } } - const scrollSlashActiveIntoView = () => { + // Auto-scroll active command into view when navigating with keyboard + createEffect(() => { const activeId = slashActive() if (!activeId || !slashPopoverRef) return @@ -826,7 +829,7 @@ export const PromptInput: Component = (props) => { const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`) element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) }) - } + }) const selectPopoverActive = () => { if (store.popover === "at") { const items = atFlat() @@ -888,29 +891,12 @@ export const PromptInput: Component = (props) => { const pushFile = (file: HTMLElement) => { const content = file.textContent ?? "" - const source = - file.dataset.sourceType === "resource" && file.dataset.sourceClientName && file.dataset.sourceUri - ? { - type: "resource" as const, - text: { - value: content, - start: position, - end: position + content.length, - }, - clientName: file.dataset.sourceClientName, - uri: file.dataset.sourceUri, - } - : undefined parts.push({ type: "file", path: file.dataset.path!, content, start: position, end: position + content.length, - ...(file.dataset.mime ? { mime: file.dataset.mime } : {}), - ...(file.dataset.filename ? { filename: file.dataset.filename } : {}), - ...(file.dataset.url ? { url: file.dataset.url } : {}), - ...(source ? { source } : {}), }) position += content.length } @@ -1002,10 +988,10 @@ export const PromptInput: Component = (props) => { if (atMatch) { atOnInput(atMatch[1]) - setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" }) + setStore("popover", "at") } else if (slashMatch) { slashOnInput(slashMatch[1]) - setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" }) + setStore("popover", "slash") } else { closePopover() } @@ -1129,7 +1115,7 @@ export const PromptInput: Component = (props) => { } setStore("mode", "normal") - closePopover() + setStore("popover", null) setStore("historyIndex", -1) setStore("savedPrompt", null) prompt.set(edit.prompt, promptLength(edit.prompt)) @@ -1216,17 +1202,13 @@ export const PromptInput: Component = (props) => { resetHistoryNavigation(true) }, setMode: (mode) => setStore("mode", mode), - setPopover: (popover) => { - if (!popover) return closePopover() - setStore({ popover, slashMenu: false, slashMenuQuery: "" }) - }, + setPopover: (popover) => setStore("popover", popover), newSessionWorktree: () => props.newSessionWorktree, onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, shouldQueue: props.shouldQueue, onQueue: props.onQueue, onAbort: props.onAbort, onSubmit: props.onSubmit, - model: props.controls.model.selection, }) const handleKeyDown = (event: KeyboardEvent) => { @@ -1259,7 +1241,7 @@ export const PromptInput: Component = (props) => { const cursorPosition = getCursorPosition(editorRef) if (cursorPosition === 0) { setStore("mode", "shell") - closePopover() + setStore("popover", null) event.preventDefault() return } @@ -1334,9 +1316,6 @@ export const PromptInput: Component = (props) => { } if (store.popover === "slash") { slashOnKeyDown(event) - if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) { - scrollSlashActiveIntoView() - } } event.preventDefault() return @@ -1394,44 +1373,125 @@ export const PromptInput: Component = (props) => { } } - const handleSlashMenuKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - closePopover() - requestAnimationFrame(() => editorRef.focus()) - event.preventDefault() - return - } - - if (event.key === "Tab") { - selectPopoverActive() - event.preventDefault() - return - } - - const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey - const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter" - const ctrlNav = ctrl && (event.key === "n" || event.key === "p") - if (!nav && !ctrlNav) return - slashOnKeyDown(event) - if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) scrollSlashActiveIntoView() - event.preventDefault() - } - const agentsLoading = () => props.controls.agents.loading - const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading()) + const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading()) const providersLoading = () => props.controls.model.loading - const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading()) + const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading()) const [promptReady] = createResource( - () => prompt.ready.promise, + () => prompt.ready().promise, (p) => p, ) - const bindEditorRef = (el: HTMLDivElement) => { - editorRef = el - restoreEndOnFocus = true - props.ref?.(el) + const designPlaceholder = () => { + if (store.mode === "shell") return placeholder() + return "Ask anything, / for commands, @ for context..." } + + const modelControlState = createMemo(() => ({ + loading: providersLoading(), + paid: props.controls.model.paid, + title: language.t("command.model.choose"), + keybind: command.keybind("model.choose"), + model: props.controls.model.selection, + providerID: props.controls.model.selection.current()?.provider?.id, + modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"), + style: control(), + onClose: restoreFocus, + onUnpaidClick: () => { + void import("@/components/dialog-select-model-unpaid").then((x) => { + dialog.show(() => ) + }) + }, + })) + + const newSession = () => props.variant === "new-session" + const projects = createMemo(() => props.controls.projects.available) + const projectForDirectory = (directory: string | undefined) => { + if (!directory) return + const key = pathKey(directory) + return projects().find( + (project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key), + ) + } + const selectedProject = createMemo(() => projectForDirectory(props.controls.projects.directory)) + const projectResults = createMemo(() => { + const search = picker.projectSearch.trim().toLowerCase() + if (!search) return projects() + return projects().filter((project) => displayName(project).toLowerCase().includes(search)) + }) + const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0) + const selectProject = (worktree: string) => { + setPicker({ + projectOpen: false, + projectSearch: "", + }) + if (pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "")) { + restoreFocus() + return + } + props.controls.projects.select(worktree) + restoreFocus() + } + const addProject = () => { + props.controls.projects.add(language.t("command.project.open")) + } + + const projectPickerState = createMemo(() => ({ + open: picker.projectOpen, + trigger: { + action: "prompt-project", + icon: "folder", + label: selectedProject() ? displayName(selectedProject()!) : language.t("session.new.project.new"), + class: "max-w-[203px]", + style: control(), + onPress: () => setPicker("projectOpen", true), + }, + search: picker.projectSearch, + searchPlaceholder: language.t("session.new.project.search"), + clearLabel: language.t("common.clear"), + items: projectResults().map((project) => ({ + icon: "folder", + label: displayName(project), + selected: selectedProject()?.worktree === project.worktree, + onSelect: () => selectProject(project.worktree), + })), + action: { + icon: "plus", + label: language.t("session.new.project.add"), + onSelect: () => { + setPicker("projectOpen", false) + void addProject() + }, + }, + onOpenChange: (open) => { + setPicker("projectOpen", open) + if (open) requestAnimationFrame(() => projectSearchRef?.focus()) + }, + onSearchInput: (value) => setPicker("projectSearch", value), + onSearchClear: () => setPicker("projectSearch", ""), + searchRef: (el) => (projectSearchRef = el), + })) + const agentControlState = createMemo(() => ({ + title: language.t("command.agent.cycle"), + keybind: command.keybind("agent.cycle"), + options: props.controls.agents.options, + current: props.controls.agents.current, + style: control(), + onSelect: (value) => { + props.controls.agents.select(value) + restoreFocus() + }, + })) + const newProjectTriggerState = createMemo(() => ({ + action: "prompt-project", + icon: "folder-add-left", + label: language.t("session.new.project.new"), + class: "max-w-[160px]", + style: control(), + onPress: () => void addProject(), + })) + return (
{(promptReady(), null)} @@ -1447,290 +1507,464 @@ export const PromptInput: Component = (props) => { slashActive={slashActive() ?? undefined} setSlashActive={setSlashActive} onSlashSelect={handleSlashSelect} - slashMenu={store.slashMenu} - slashMenuQuery={store.slashMenuQuery} - onSlashMenuInput={(value) => { - setStore("slashMenuQuery", value) - slashOnInput(value) - }} - onSlashMenuKeyDown={handleSlashMenuKeyDown} commandKeybind={command.keybind} - commandKeybindParts={command.keybindParts} - newLayoutDesigns={false} t={(key) => language.t(key as Parameters[0])} /> - - - { - const active = comments.active() - return !!item.commentID && item.commentID === active?.id && item.path === active?.file - }} - openComment={openComment} - remove={(item) => { - if (item.commentID) comments.remove(item.path, item.commentID) - prompt.context.remove(item.key) - }} - newLayoutDesigns={false} - t={(key) => language.t(key as Parameters[0])} - /> - - dialog.show(() => ) - } - onRemove={removeAttachment} - removeLabel={language.t("prompt.attachment.remove")} - newLayoutDesigns={false} - /> -
{ - const target = e.target - if (!(target instanceof HTMLElement)) return - if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) { - return - } - editorRef?.focus() - }} - > -
(scrollRef = el)} - style={{ "scroll-padding-bottom": space }} - > -
+ +
+ -
- {placeholder()} -
-
- - - - - -
-
+ + { + const active = comments.active() + return !!item.commentID && item.commentID === active?.id && item.path === active?.file + }} + openComment={openComment} + remove={(item) => { + if (item.commentID) comments.remove(item.path, item.commentID) + prompt.context.remove(item.key) + }} + t={(key) => language.t(key as Parameters[0])} + /> + + dialog.show(() => ) + } + onRemove={removeAttachment} + removeLabel={language.t("prompt.attachment.remove")} + />
{ + const target = e.target + if (!(target instanceof HTMLElement)) return + if (target.closest('[data-action^="prompt-"]')) return + editorRef?.focus() }} > - - {language.t("prompt.mode.shell")} -
- -
-
- -
- - (x === "default" ? language.t("common.default") : x)} + onSelect={(value) => { + props.controls.model.selection.variant.set(value === "default" ? undefined : value) + restoreFocus() + }} + class="capitalize max-w-[160px] text-text-base" + valueClass="truncate text-13-regular text-text-base" + triggerStyle={control()} + triggerProps={{ "data-action": "prompt-model-variant" }} + variant="ghost" + /> + +
+
-
- -
- - props.state.onSearchInput(event.currentTarget.value)} + /> + + + +
+ {(item) => } +
+
+
+ +
+ + + + ) +} + +function ComposerAgentControl(props: { state: ComposerAgentControlState }) { + return ( +
+
+ +
+ + requestAnimationFrame(() => el.focus())} - value={props.slashMenuQuery} - onInput={(event) => props.onSlashMenuInput(event.currentTarget.value)} - onKeyDown={props.onSlashMenuKeyDown} - onMouseDown={(event) => event.stopPropagation()} - aria-label={props.t("prompt.menu.commands")} - placeholder="/" - class="w-full bg-transparent outline-none text-[13px] leading-5 text-v2-text-text-base placeholder:text-v2-text-text-faint" - /> -
- 0} - fallback={ -
- {props.t("prompt.popover.emptyCommands")} -
- } + fallback={
{props.t("prompt.popover.emptyCommands")}
} > - {(cmd) => { - const keybind = () => props.commandKeybind(cmd.id) - const keybindParts = () => props.commandKeybindParts(cmd.id) - return ( - - ) - }} +
+ + {props.commandKeybind(cmd.id)} + +
+ + )} diff --git a/packages/app/src/components/prompt-input/submission-state.ts b/packages/app/src/components/prompt-input/submission-state.ts deleted file mode 100644 index f9ca735609..0000000000 --- a/packages/app/src/components/prompt-input/submission-state.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { type ContextItem, type Prompt, type usePrompt } from "@/context/prompt" - -type PromptTarget = ReturnType["capture"]> - -export function createPromptSubmissionState(input: { - target: PromptTarget - prompt: Prompt - context: (ContextItem & { key: string })[] -}) { - const initial = input.target - let target = input.target - let cleared: Prompt | undefined - - return { - prompt: input.prompt, - context: input.context, - target: () => target, - clear() { - if (initial !== target) initial.reset() - target.reset() - cleared = target.current() - }, - retarget(next: PromptTarget) { - input.context.forEach(next.context.add) - target = next - }, - current: (value: PromptTarget) => target === value, - restore() { - if (cleared !== undefined && target.current() !== cleared) return - return { target, prompt: input.prompt, context: input.context } - }, - } -} diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index b3201b3ef6..7b412cd245 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -1,18 +1,11 @@ import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" -import { createStore } from "solid-js/store" -import type { Prompt, PromptStore } from "@/context/prompt" -import type { ModelSelection } from "@/context/local" +import type { Prompt } from "@/context/prompt" let createPromptSubmit: typeof import("./submit").createPromptSubmit const createdClients: string[] = [] const createdSessions: string[] = [] -const sessionCreateInputs: Array<{ - agent?: string - model?: { id: string; providerID: string; variant?: string } - location?: { directory: string } -}> = [] -const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = [] +const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = [] const optimistic: Array<{ directory?: string sessionID?: string @@ -25,38 +18,19 @@ const optimistic: Array<{ const optimisticSeeded: boolean[] = [] const storedSessions: Record> = {} const promoted: Array<{ directory: string; sessionID: string }> = [] -const sentShell: Array<{ sessionID: string; id?: string; command: string }> = [] +const sentShell: string[] = [] const syncedDirectories: string[] = [] -const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = [] -const sentPrompts: string[] = [] -const promptInputs: unknown[] = [] -const sentCommands: unknown[] = [] -const commands: Array<{ name: string }> = [] -let serverSessionSyncs = 0 let params: { id?: string } = {} -let search: { draftId?: string } = {} let selected = "/repo/worktree-a" let variant: string | undefined -let permissionServer = "server-a" -let createSessionGate: Promise | undefined -let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] -const [promptStore, setPromptStore] = createStore({ - prompt: promptValue, - cursor: 0, - context: { items: [] }, -}) +const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] const prompt = { - store: [() => promptStore, setPromptStore] as [() => PromptStore, typeof setPromptStore], - ready: Object.assign(() => true, { promise: Promise.resolve(true) }), + ready: () => Object.assign(() => true, { promise: Promise.resolve(true) }), current: () => promptValue, cursor: () => 0, dirty: () => true, - model: { - current: () => undefined, - set: () => undefined, - }, reset: () => undefined, set: () => undefined, context: { @@ -67,45 +41,27 @@ const prompt = { replaceComments: () => undefined, items: () => [], }, - capture: () => prompt, } const clientFor = (directory: string) => { createdClients.push(directory) return { - api: { - session: { - create: async (input: (typeof sessionCreateInputs)[number]) => { - await createSessionGate - const location = input.location?.directory ?? directory - createdSessions.push(location) - sessionCreateInputs.push(input) - return { - id: `session-${createdSessions.length}`, - projectID: "project", - agent: input.agent, - model: input.model, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1, updated: 1 }, - title: `New session ${createdSessions.length}`, - location: { directory: location }, - } - }, - prompt: async (input: unknown) => { - sentPrompts.push(directory) - promptInputs.push(input) - return { data: undefined } - }, - command: async (input: unknown) => { - sentCommands.push(input) - }, - shell: async (input: { sessionID: string; id?: string; command: string }) => { - sentShell.push(input) - }, - }, - }, session: { + create: async () => { + createdSessions.push(directory) + return { + data: { + id: `session-${createdSessions.length}`, + title: `New session ${createdSessions.length}`, + }, + } + }, + shell: async () => { + sentShell.push(directory) + return { data: undefined } + }, + prompt: async () => ({ data: undefined }), + promptAsync: async () => ({ data: undefined }), command: async () => ({ data: undefined }), abort: async () => ({ data: undefined }), }, @@ -122,7 +78,7 @@ beforeAll(async () => { useNavigate: () => () => undefined, useParams: () => params, useLocation: () => ({}), - useSearchParams: () => [search, () => undefined], + useSearchParams: () => [{}, () => undefined], })) mock.module("@opencode-ai/sdk/v2/client", () => ({ @@ -158,14 +114,13 @@ beforeAll(async () => { }), })) - mock.module("@/context/permission", () => { - const state = (server: string) => ({ + mock.module("@/context/permission", () => ({ + usePermission: () => ({ enableAutoAccept(sessionID: string, directory: string) { - enabledAutoAccept.push({ server, sessionID, directory }) + enabledAutoAccept.push({ sessionID, directory }) }, - }) - return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) } - }) + }), + })) mock.module("@/context/server", () => ({ useServer: () => ({ key: "server-key" }), @@ -173,10 +128,7 @@ beforeAll(async () => { mock.module("@/context/tabs", () => ({ useTabs: () => ({ - draft: () => ({ server: "project-server" }), - promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => { - promotedDrafts.push({ draftID, ...session }) - }, + promoteDraft: () => undefined, }), })) @@ -198,7 +150,6 @@ beforeAll(async () => { scope: "local", directory: "/repo/main", client: rootClient, - api: rootClient.api, url: "http://localhost:4096", createClient(opts: any) { return clientFor(opts.directory) @@ -210,7 +161,7 @@ beforeAll(async () => { mock.module("@/context/sync", () => ({ useSync: () => () => ({ - data: { command: commands }, + data: { command: [] }, session: { optimistic: { add: (value: { @@ -234,13 +185,6 @@ beforeAll(async () => { mock.module("@/context/server-sync", () => ({ useServerSync: () => () => ({ - session: { - remember: () => undefined, - set: () => undefined, - sync: async () => { - serverSessionSyncs++ - }, - }, child: (directory: string) => { syncedDirectories.push(directory) storedSessions[directory] ??= [] @@ -281,26 +225,15 @@ beforeAll(async () => { beforeEach(() => { createdClients.length = 0 createdSessions.length = 0 - sessionCreateInputs.length = 0 enabledAutoAccept.length = 0 optimistic.length = 0 optimisticSeeded.length = 0 promoted.length = 0 - promotedDrafts.length = 0 - sentPrompts.length = 0 - promptInputs.length = 0 - sentCommands.length = 0 - commands.length = 0 - promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }] params = {} - search = {} sentShell.length = 0 syncedDirectories.length = 0 selected = "/repo/worktree-a" variant = undefined - permissionServer = "server-a" - createSessionGate = undefined - serverSessionSyncs = 0 for (const key of Object.keys(storedSessions)) delete storedSessions[key] }) @@ -334,24 +267,8 @@ describe("prompt submit worktree selection", () => { expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) - expect(sessionCreateInputs).toEqual([ - { - agent: "agent", - model: { id: "model", providerID: "provider", variant: undefined }, - location: { directory: "/repo/worktree-a" }, - }, - { - agent: "agent", - model: { id: "model", providerID: "provider", variant: undefined }, - location: { directory: "/repo/worktree-b" }, - }, - ]) - expect(sentShell).toEqual([ - expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }), - expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }), - ]) + expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"]) - expect(serverSessionSyncs).toBe(0) expect(promoted).toEqual([ { directory: "/repo/worktree-a", sessionID: "session-1" }, { directory: "/repo/worktree-b", sessionID: "session-2" }, @@ -384,67 +301,7 @@ describe("prompt submit worktree selection", () => { await submit.handleSubmit(event) - expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }]) - }) - - test("keeps auto-accept bound to the submission server", async () => { - let release = () => {} - createSessionGate = new Promise((resolve) => { - release = resolve - }) - const submit = createPromptSubmit({ - prompt, - info: () => undefined, - imageAttachments: () => [], - commentCount: () => 0, - autoAccept: () => true, - mode: () => "shell", - working: () => false, - editor: () => undefined, - queueScroll: () => undefined, - promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), - addToHistory: () => undefined, - resetHistoryNavigation: () => undefined, - setMode: () => undefined, - setPopover: () => undefined, - newSessionWorktree: () => selected, - onNewSessionWorktreeReset: () => undefined, - onSubmit: () => undefined, - }) - - const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) - permissionServer = "server-b" - release() - await result - - expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }]) - }) - - test("promotes drafts using the selected project's server", async () => { - search = { draftId: "draft-1" } - const submit = createPromptSubmit({ - prompt, - info: () => undefined, - imageAttachments: () => [], - commentCount: () => 0, - autoAccept: () => false, - mode: () => "normal", - working: () => false, - editor: () => undefined, - queueScroll: () => undefined, - promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), - addToHistory: () => undefined, - resetHistoryNavigation: () => undefined, - setMode: () => undefined, - setPopover: () => undefined, - newSessionWorktree: () => selected, - onNewSessionWorktreeReset: () => undefined, - onSubmit: () => undefined, - }) - - await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) - - expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }]) + expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }]) }) test("includes the selected variant on optimistic prompts", async () => { @@ -472,7 +329,6 @@ describe("prompt submit worktree selection", () => { const event = { preventDefault: () => undefined } as unknown as Event await submit.handleSubmit(event) - await Bun.sleep(0) expect(optimistic).toHaveLength(1) expect(optimistic[0]).toMatchObject({ @@ -481,89 +337,6 @@ describe("prompt submit worktree selection", () => { model: { providerID: "provider", modelID: "model", variant: "high" }, }, }) - expect(sentPrompts).toEqual(["/repo/main"]) - expect(promptInputs[0]).toMatchObject({ - sessionID: "session-1", - text: "ls", - files: [], - agents: [], - }) - expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_") - expect((promptInputs[0] as { legacyParts?: { id: string; type: string; text?: string }[] }).legacyParts).toEqual([ - { id: expect.stringMatching(/^prt_/), type: "text", text: "ls" }, - ]) - }) - - test("submits slash commands through the current session API", async () => { - params = { id: "session-1" } - variant = "high" - commands.push({ name: "review" }) - promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }] - - const submit = createPromptSubmit({ - prompt, - info: () => ({ id: "session-1" }), - imageAttachments: () => [], - commentCount: () => 0, - autoAccept: () => false, - mode: () => "normal", - working: () => false, - editor: () => undefined, - queueScroll: () => undefined, - promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), - addToHistory: () => undefined, - resetHistoryNavigation: () => undefined, - setMode: () => undefined, - setPopover: () => undefined, - }) - - await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) - - expect(sentCommands).toEqual([ - { - sessionID: "session-1", - id: expect.stringMatching(/^msg_/), - command: "review", - arguments: "staged changes", - agent: "agent", - model: { id: "model", providerID: "provider", variant: "high" }, - files: [], - }, - ]) - expect(serverSessionSyncs).toBe(0) - }) - - test("uses an injected model selection", async () => { - params = { id: "session-1" } - const model = { - current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }), - variant: { current: () => "draft-variant" }, - } as unknown as ModelSelection - const submit = createPromptSubmit({ - prompt, - info: () => ({ id: "session-1" }), - imageAttachments: () => [], - commentCount: () => 0, - autoAccept: () => false, - mode: () => "normal", - working: () => false, - editor: () => undefined, - queueScroll: () => undefined, - promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), - addToHistory: () => undefined, - resetHistoryNavigation: () => undefined, - setMode: () => undefined, - setPopover: () => undefined, - model, - }) - - await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) - - expect(optimistic[0]).toMatchObject({ - message: { - model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" }, - }, - }) }) test("seeds new sessions before optimistic prompts are added", async () => { @@ -591,8 +364,7 @@ describe("prompt submit worktree selection", () => { await submit.handleSubmit(event) - expect(storedSessions["/repo/worktree-a"]).toHaveLength(1) - expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" }) + expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }]) expect(optimisticSeeded).toEqual([true]) }) }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index c82c3a4399..a95f0a6030 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -3,12 +3,14 @@ import { showToast } from "@/utils/toast" import { base64Encode } from "@opencode-ai/core/util/encode" import { Binary } from "@opencode-ai/core/util/binary" import { useNavigate, useParams, useSearchParams } from "@solidjs/router" -import { batch, startTransition, type Accessor } from "solid-js" +import { batch, type Accessor } from "solid-js" +import type { FileSelection } from "@/context/file" +import { useServer } from "@/context/server" import { useTabs } from "@/context/tabs" import { useServerSync, type ServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" -import { useLocal, type ModelSelection } from "@/context/local" +import { useLocal } from "@/context/local" import { usePermission } from "@/context/permission" import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt" import { useSDK, type DirectorySDK } from "@/context/sdk" @@ -19,10 +21,6 @@ import { buildRequestParts } from "./build-request-parts" import { setCursorPosition } from "./editor-dom" import { formatServerError } from "@/utils/server-errors" import { ScopedKey } from "@/utils/server-scope" -import { createPromptSubmissionState } from "./submission-state" -import { normalizeSessionInfo } from "@/utils/session" -import { Event } from "@opencode-ai/schema/event" -import { blobDataUrl } from "@/utils/draft-store" type PendingPrompt = { abort: AbortController @@ -42,7 +40,7 @@ export type FollowupDraft = { } type FollowupSendInput = { - api: DirectorySDK["api"]["session"] + client: DirectorySDK["client"] serverSync: ServerSync sync: DirectorySync draft: FollowupDraft @@ -58,14 +56,16 @@ const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttac export async function sendFollowupDraft(input: FollowupSendInput) { const text = draftText(input.draft.prompt) const images = draftImages(input.draft.prompt) + const [, setStore] = input.serverSync.child(input.draft.sessionDirectory) + const setBusy = () => { if (!input.optimisticBusy) return - input.serverSync.session.set("session_status", input.draft.sessionID, { type: "busy" }) + setStore("session_status", input.draft.sessionID, { type: "busy" }) } const setIdle = () => { if (!input.optimisticBusy) return - input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" }) + setStore("session_status", input.draft.sessionID, { type: "idle" }) } const wait = async () => { @@ -84,24 +84,20 @@ export async function sendFollowupDraft(input: FollowupSendInput) { return false } - const messageID = Identifier.ascending("message") - await input.api.command({ + await input.client.session.command({ sessionID: input.draft.sessionID, - id: messageID, command: cmd, arguments: tail.join(" "), agent: input.draft.agent, - model: { - id: input.draft.model.modelID, - providerID: input.draft.model.providerID, - variant: input.draft.variant, - }, - files: await Promise.all( - images.map(async (attachment) => ({ - uri: await blobDataUrl(attachment.blob, attachment.mime), - name: attachment.filename, - })), - ), + model: `${input.draft.model.providerID}/${input.draft.model.modelID}`, + variant: input.draft.variant, + parts: images.map((attachment) => ({ + id: Identifier.ascending("part"), + type: "file" as const, + mime: attachment.mime, + url: attachment.dataUrl, + filename: attachment.filename, + })), }) return true } catch (err) { @@ -111,16 +107,10 @@ export async function sendFollowupDraft(input: FollowupSendInput) { } const messageID = input.messageID ?? Identifier.ascending("message") - const encodedImages = await Promise.all( - images.map(async (attachment) => ({ - ...attachment, - dataUrl: await blobDataUrl(attachment.blob, attachment.mime), - })), - ) const { requestParts, optimisticParts } = buildRequestParts({ prompt: input.draft.prompt, context: input.draft.context, - images: encodedImages, + images, text, sessionID: input.draft.sessionID, messageID, @@ -165,37 +155,13 @@ export async function sendFollowupDraft(input: FollowupSendInput) { return false } - await input.api.prompt({ + await input.client.session.promptAsync({ sessionID: input.draft.sessionID, - id: messageID, agent: input.draft.agent, model: input.draft.model, + messageID, + parts: requestParts, variant: input.draft.variant, - legacyParts: requestParts, - text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"), - files: requestParts.flatMap((part) => { - if (part.type !== "file") return [] - const text = part.source?.text - return [ - { - uri: part.url, - name: part.filename, - mention: text ? { start: text.start, end: text.end, text: text.value } : undefined, - }, - ] - }), - agents: requestParts.flatMap((part) => - part.type === "agent" - ? [ - { - name: part.name, - mention: part.source - ? { start: part.source.start, end: part.source.end, text: part.source.value } - : undefined, - }, - ] - : [], - ), }) return true } catch (err) { @@ -228,7 +194,15 @@ type PromptSubmitInput = { onQueue?: (draft: FollowupDraft) => void onAbort?: () => void onSubmit?: () => void - model?: ModelSelection +} + +type CommentItem = { + path: string + selection?: FileSelection + comment?: string + commentID?: string + commentOrigin?: "review" | "file" + preview?: string } export function createPromptSubmit(input: PromptSubmitInput) { @@ -243,11 +217,11 @@ export function createPromptSubmit(input: PromptSubmitInput) { const language = useLanguage() const params = useParams() const [search] = useSearchParams<{ draftId?: string }>() + const server = useServer() const tabs = useTabs() const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID) const errorMessage = (err: unknown) => { - if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message if (err && typeof err === "object" && "data" in err) { const data = (err as { data?: { message?: string } }).data if (data?.message) return data.message @@ -260,7 +234,9 @@ export function createPromptSubmit(input: PromptSubmitInput) { const sessionID = params.id if (!sessionID) return Promise.resolve() - serverSync().session.set("todo", sessionID, []) + serverSync().todo.set(sessionID, []) + const [, setStore] = serverSync().child(sdk().directory) + setStore("todo", sessionID, []) input.onAbort?.() @@ -273,16 +249,15 @@ export function createPromptSubmit(input: PromptSubmitInput) { return Promise.resolve() } return sdk() - .api.session.interrupt({ sessionID }) + .client.session.abort({ + sessionID, + }) .catch(() => {}) } - const restoreCommentItems = ( - target: ReturnType["capture"]>, - items: (ContextItem & { key: string })[], - ) => { + const restoreCommentItems = (items: CommentItem[]) => { for (const item of items) { - target.context.add({ + prompt.context.add({ type: "file", path: item.path, selection: item.selection, @@ -294,14 +269,19 @@ export function createPromptSubmit(input: PromptSubmitInput) { } } - const clearContext = (target: ReturnType["capture"]>) => { - for (const item of target.context.items()) { - target.context.remove(item.key) + const removeCommentItems = (items: { key: string }[]) => { + for (const item of items) { + prompt.context.remove(item.key) + } + } + + const clearContext = () => { + for (const item of prompt.context.items()) { + prompt.context.remove(item.key) } } const seed = (dir: string, info: Session) => { - serverSync().session.remember(info) const [, setStore] = serverSync().child(dir) setStore("session", (list: Session[]) => { const result = Binary.search(list, info.id, (item) => item.id) @@ -318,14 +298,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const handleSubmit = async (event: Event) => { event.preventDefault() - const target = prompt.capture() - const submission = createPromptSubmissionState({ - target, - prompt: target.current(), - context: target.context.items().slice(), - }) - const currentPrompt = submission.prompt - const context = submission.context + const currentPrompt = prompt.current() const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") const images = input.imageAttachments().slice() const mode = input.mode() @@ -335,10 +308,9 @@ export function createPromptSubmit(input: PromptSubmitInput) { return } - const modelSelection = input.model ?? local.model - const currentModel = modelSelection.current() + const currentModel = local.model.current() const currentAgent = local.agent.current() - const variant = modelSelection.variant.current() + const variant = local.model.variant.current() if (!currentModel || !currentAgent) { showToast({ title: language.t("prompt.toast.modelAgentRequired.title"), @@ -351,7 +323,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { input.resetHistoryNavigation() const projectDirectory = sdk().directory - const permissionState = permission.currentServerState() const isNewSession = !params.id const shouldAutoAccept = isNewSession && input.autoAccept() const worktreeSelection = input.newSessionWorktree?.() || "main" @@ -400,13 +371,9 @@ export function createPromptSubmit(input: PromptSubmitInput) { let session = input.info() if (!session && isNewSession) { - const created = await sdk() - .api.session.create({ - agent: currentAgent.name, - model: { id: currentModel.id, providerID: currentModel.provider.id, variant }, - location: { directory: sessionDirectory }, - }) - .then(normalizeSessionInfo) + const created = await client.session + .create() + .then((x) => x.data ?? undefined) .catch((err) => { showToast({ title: language.t("prompt.toast.sessionCreateFailed.title"), @@ -417,20 +384,17 @@ export function createPromptSubmit(input: PromptSubmitInput) { if (created) { seed(sessionDirectory, created) session = created - await startTransition(() => { - if (!session) return - if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory) - local.session.promote(sessionDirectory, session.id, { - agent: currentAgent.name, - model: { providerID: currentModel.provider.id, modelID: currentModel.id }, - variant: variant ?? null, + if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory) + local.session.promote(sessionDirectory, session.id) + layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) + const draftID = search.draftId + if (draftID) + tabs.promoteDraft(draftID, { + server: server.key, + dirBase64: base64Encode(sessionDirectory), + sessionId: session.id, }) - layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) - const draftID = search.draftId - if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id }) - else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) - submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id })) - }) + else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) } } if (!session) { @@ -446,6 +410,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { providerID: currentModel.provider.id, } const agent = currentAgent.name + const context = prompt.context.items().slice() const draft: FollowupDraft = { sessionID: session.id, sessionDirectory, @@ -457,16 +422,13 @@ export function createPromptSubmit(input: PromptSubmitInput) { } const clearInput = () => { - submission.clear() + prompt.reset() input.setMode("normal") input.setPopover(null) } const restoreInput = () => { - const restored = submission.restore() - if (!restored) return false - restored.target.set(restored.prompt, input.promptLength(restored.prompt)) - if (!submission.current(prompt.capture())) return true + prompt.set(currentPrompt, input.promptLength(currentPrompt)) input.setMode(mode) input.setPopover(null) requestAnimationFrame(() => { @@ -476,12 +438,11 @@ export function createPromptSubmit(input: PromptSubmitInput) { setCursorPosition(editor, input.promptLength(currentPrompt)) input.queueScroll() }) - return true } if (!isNewSession && mode === "normal" && input.shouldQueue?.()) { input.onQueue?.(draft) - clearContext(submission.target()) + clearContext() clearInput() return } @@ -490,14 +451,12 @@ export function createPromptSubmit(input: PromptSubmitInput) { if (mode === "shell") { clearInput() - const eventID = Event.ID.create() - sdk() - .api.session.shell({ + client.session + .shell({ sessionID: session.id, - id: eventID, - command: text, agent, model, + command: text, }) .catch((err) => { showToast({ @@ -515,25 +474,23 @@ export function createPromptSubmit(input: PromptSubmitInput) { const customCommand = sync().data.command.find((c) => c.name === commandName) if (customCommand) { clearInput() - const messageID = Identifier.ascending("message") - serverSync().session.set("session_status", session.id, { type: "busy" }) - sdk() - .api.session.command({ + client.session + .command({ sessionID: session.id, - id: messageID, command: commandName, arguments: args.join(" "), agent, - model: { id: model.modelID, providerID: model.providerID, variant }, - files: await Promise.all( - images.map(async (attachment) => ({ - uri: await blobDataUrl(attachment.blob, attachment.mime), - name: attachment.filename, - })), - ), + model: `${model.providerID}/${model.modelID}`, + variant, + parts: images.map((attachment) => ({ + id: Identifier.ascending("part"), + type: "file" as const, + mime: attachment.mime, + url: attachment.dataUrl, + filename: attachment.filename, + })), }) .catch((err) => { - serverSync().session.set("session_status", session.id, { type: "idle" }) showToast({ title: language.t("prompt.toast.commandSendFailed.title"), description: formatServerError(err, language.t, language.t("common.requestFailed")), @@ -555,7 +512,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { }) } - for (const item of commentItems) submission.target().context.remove(item.key) + removeCommentItems(commentItems) clearInput() const waitForWorktree = async () => { @@ -572,7 +529,8 @@ export function createPromptSubmit(input: PromptSubmitInput) { sync().set("session_status", session.id, { type: "idle" }) } removeOptimisticMessage() - if (restoreInput()) restoreCommentItems(submission.target(), commentItems) + restoreCommentItems(commentItems) + restoreInput() } pending.set(pendingKey(session.id), { abort: controller, cleanup }) @@ -617,7 +575,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { } void sendFollowupDraft({ - api: sdk().api.session, + client, sync: sync(), serverSync: serverSync(), draft, @@ -634,7 +592,8 @@ export function createPromptSubmit(input: PromptSubmitInput) { description: errorMessage(err), }) removeOptimisticMessage() - if (restoreInput()) restoreCommentItems(submission.target(), commentItems) + restoreCommentItems(commentItems) + restoreInput() }) } diff --git a/packages/app/src/components/prompt-input/transient-state.ts b/packages/app/src/components/prompt-input/transient-state.ts deleted file mode 100644 index 29f2f182b3..0000000000 --- a/packages/app/src/components/prompt-input/transient-state.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { createComputed, on, type Accessor } from "solid-js" -import { createStore, type SetStoreFunction } from "solid-js/store" -import type { PromptHistoryEntry } from "./history" - -export type PromptInputTransientState = { - popover: "at" | "slash" | null - slashMenu: boolean - slashMenuQuery: string - historyIndex: number - savedPrompt: PromptHistoryEntry | null - placeholder: number - draggingType: "image" | "@mention" | null - mode: "normal" | "shell" - applyingHistory: boolean -} - -function resetPromptInputTransientState(setStore: SetStoreFunction) { - setStore({ - popover: null, - slashMenu: false, - slashMenuQuery: "", - historyIndex: -1, - savedPrompt: null, - draggingType: null, - mode: "normal", - applyingHistory: false, - }) -} - -export function createPromptInputTransientState(identity: Accessor, placeholder: number) { - const [store, setStore] = createStore({ - popover: null, - slashMenu: false, - slashMenuQuery: "", - historyIndex: -1, - savedPrompt: null, - placeholder, - draggingType: null, - mode: "normal", - applyingHistory: false, - }) - - createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true })) - - return [store, setStore] as const -} diff --git a/packages/app/src/components/prompt-project-selector.tsx b/packages/app/src/components/prompt-project-selector.tsx deleted file mode 100644 index b84afdb56b..0000000000 --- a/packages/app/src/components/prompt-project-selector.tsx +++ /dev/null @@ -1,589 +0,0 @@ -import { - createEffect, - createSignal, - For, - onCleanup, - Show, - splitProps, - type Accessor, - type ComponentProps, -} from "solid-js" -import { createStore } from "solid-js/store" -import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Icon } from "@opencode-ai/ui/icon" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" -import { getProjectAvatarVariant } from "@/context/layout" -import { useLanguage } from "@/context/language" -import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" -import { pathKey } from "@/utils/path-key" -import { handleDocumentSearchKeydown } from "@/utils/search-keydown" -import { createMenuDismissController } from "@/utils/menu-dismiss-controller" - -export type PromptProject = { - name?: string - id?: string - worktree: string - sandboxes?: string[] - icon?: { color?: string; url?: string; override?: string } - server?: { key: string; name: string } -} - -export type PromptProjectControls = { - available: PromptProject[] - directory: string - server?: string - select: (worktree: string, server?: string) => void - add: (title: string, server?: string) => void -} - -const actionPrefix = "action:" -const projectPrefix = "project:" - -function projectKey(project: PromptProject) { - return `${projectPrefix}${encodeURIComponent(project.server?.key ?? "")}:${encodeURIComponent(project.worktree)}` -} - -function actionKey(server?: string) { - return `${actionPrefix}${encodeURIComponent(server ?? "")}` -} - -export function createPromptProjectController(input: { - controls: Accessor - onDone: () => void -}) { - const language = useLanguage() - const [store, setStore] = createStore({ open: false, search: "", active: "" }) - let searchRef: HTMLInputElement | undefined - - const current = () => { - const key = pathKey(input.controls().directory) - return input - .controls() - .available.find( - (project) => - (!project.server || project.server.key === input.controls().server) && - (pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)), - ) - } - const selected = () => current() ?? input.controls().available[0] - const projects = () => { - const search = store.search.trim().toLowerCase() - if (!search) return input.controls().available - return input.controls().available.filter((project) => displayName(project).toLowerCase().includes(search)) - } - const servers = () => - input - .controls() - .available.map((project) => project.server) - .filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index) - const keys = () => { - if (servers().length <= 1) { - return [...projects().map(projectKey), actionKey(servers()[0]?.key)] - } - return [ - ...servers().flatMap((server) => - projects() - .filter((project) => project.server?.key === server!.key) - .map(projectKey), - ), - actionKey(), - ] - } - const initialActive = () => { - const selectedKey = selected() ? projectKey(selected()!) : undefined - const options = keys() - if (selectedKey && options.includes(selectedKey)) return selectedKey - return options[0] ?? "" - } - const close = () => { - setStore({ open: false, search: "", active: "" }) - input.onDone() - } - const select = (project: PromptProject) => { - if ( - pathKey(project.worktree) !== pathKey(current()?.worktree ?? "") || - project.server?.key !== current()?.server?.key - ) { - input.controls().select(project.worktree, project.server?.key) - } - close() - } - const add = (server?: string) => { - setStore({ open: false, search: "", active: "" }) - input.controls().add(language.t("command.project.open"), server) - } - const setSearch = (value: string) => { - const search = value.trim().toLowerCase() - const first = input - .controls() - .available.find((project) => !search || displayName(project).toLowerCase().includes(search)) - setStore({ - search: value, - active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key), - }) - } - - return { - selected, - empty: () => input.controls().available.length === 0, - projects, - servers, - projectKey, - actionKey, - open: () => store.open, - search: () => store.search, - active: () => store.active, - labels: { - add: () => language.t("session.new.project.add"), - clear: () => language.t("common.clear"), - new: () => language.t("session.new.project.new"), - search: () => language.t("session.new.project.search"), - }, - add, - select, - setOpen(open: boolean) { - if (open) { - setStore({ open: true, active: initialActive() }) - setTimeout(() => requestAnimationFrame(() => searchRef?.focus())) - return - } - setStore({ open: false, search: "", active: "" }) - }, - setSearch, - clearSearch() { - setStore({ search: "", active: initialActive() }) - setTimeout(() => searchRef?.focus()) - }, - setActive(key: string) { - setStore("active", key) - }, - 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]) - }, - activeProject() { - return store.active.startsWith(projectPrefix) - ? projects().find((project) => projectKey(project) === store.active) - : undefined - }, - activeServer() { - return store.active.startsWith(actionPrefix) - ? decodeURIComponent(store.active.slice(actionPrefix.length)) || undefined - : undefined - }, - activeAction() { - return store.active.startsWith(actionPrefix) - }, - setSearchRef(el: HTMLInputElement) { - searchRef = el - }, - focusSearch() { - setTimeout(() => requestAnimationFrame(() => searchRef?.focus())) - }, - handleSearchKeydown(event: KeyboardEvent) { - return handleDocumentSearchKeydown(searchRef, event, store.search, setSearch) - }, - } -} - -export type PromptProjectController = ReturnType - -export function PromptProjectSelector(props: { - controller: PromptProjectController - placement?: "bottom" | "bottom-start" -}) { - const [triggerReady, setTriggerReady] = createSignal(false) - let contentRef: HTMLDivElement | undefined - const dismiss = createMenuDismissController(() => contentRef) - let triggerFrame: number | undefined - - // Floating UI requires a connected anchor; route transitions can construct this trigger before adoption. - const setTriggerRef = (element: HTMLButtonElement) => { - const ready = () => { - if (!element.isConnected) { - triggerFrame = requestAnimationFrame(ready) - return - } - triggerFrame = undefined - setTriggerReady(true) - } - ready() - } - - onCleanup(() => { - if (triggerFrame !== undefined) cancelAnimationFrame(triggerFrame) - }) - - const activeItem = () => - props.controller.active() - ? contentRef?.querySelector(`[data-option-key="${CSS.escape(props.controller.active())}"]`) - : undefined - const selectProject = (project: PromptProject) => { - dismiss.preventTriggerRestore() - props.controller.setOpen(false) - dismiss.afterClose(() => props.controller.select(project)) - } - const selectAction = (server?: string) => { - dismiss.preventTriggerRestore() - props.controller.setOpen(false) - dismiss.afterClose(() => props.controller.add(server)) - } - const selectActive = () => { - const project = props.controller.activeProject() - if (project) { - selectProject(project) - return - } - if (props.controller.activeAction() && props.controller.servers().length > 1) { - const item = activeItem() - item?.focus() - item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })) - return - } - selectAction(props.controller.activeServer()) - } - const moveActive = (delta: number) => { - props.controller.moveActive(delta) - queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" })) - } - const focusPreviousControl = () => { - const target = Array.from( - document.querySelectorAll( - 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', - ), - ) - .filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap")) - .findLast((element) => element.offsetParent !== null) - dismiss.preventTriggerRestore() - target?.focus() - queueMicrotask(() => { - if (props.controller.open()) props.controller.setOpen(false) - }) - } - const selectedValue = () => { - const project = props.controller.selected() - return project ? props.controller.projectKey(project) : undefined - } - - createEffect(() => { - if (!props.controller.open()) return - const handler = (event: KeyboardEvent) => props.controller.handleSearchKeydown(event) - document.addEventListener("keydown", handler, true) - onCleanup(() => document.removeEventListener("keydown", handler, true)) - }) - - return ( - { - if (open) dismiss.allowTriggerRestore() - props.controller.setOpen(open) - }} - > - - - event.preventDefault()} - onPointerDownOutside={dismiss.preventTriggerRestore} - onFocusOutside={dismiss.preventTriggerRestore} - onCloseAutoFocus={dismiss.onCloseAutoFocus} - > -
-
- - props.controller.setSearchRef(el)} - value={props.controller.search()} - placeholder={props.controller.labels.search()} - aria-autocomplete="list" - aria-controls="prompt-project-menu" - aria-activedescendant={props.controller.active() || undefined} - 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" - onInput={(event) => props.controller.setSearch(event.currentTarget.value)} - onKeyDown={(event) => { - if (event.key === "Tab") { - event.preventDefault() - event.stopPropagation() - if (event.shiftKey) { - focusPreviousControl() - return - } - activeItem()?.focus() - return - } - event.stopPropagation() - if (event.key === "Escape") { - event.preventDefault() - props.controller.setOpen(false) - 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() - } - }} - /> - - - -
-
- 1} - fallback={ - - - {(project) => ( - - )} - - - } - > - - props.controller.projects().some((project) => project.server?.key === server!.key), - )} - > - {(server) => ( -
-
- {server!.name} -
- - project.server?.key === server!.key)} - > - {(project) => ( - - )} - - -
- )} -
-
-
-
-
-
- 1} - fallback={ - - } - > - - props.controller.setActive(props.controller.actionKey())} - > - - - {props.controller.labels.add()} - - - - - - - {(server) => } - - - - - -
- - - - ) -} - -export function PromptProjectAddButton(props: { controller: PromptProjectController }) { - return ( - - ) -} - -function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptProjectController }) { - const [local, rest] = splitProps(props, ["controller", "class", "classList", "onClick", "onKeyDown"]) - const project = () => local.controller.selected() - return ( - - ) -} - -function ProjectItem(props: { - project: PromptProject - controller: PromptProjectController - onSelect: (project: PromptProject) => void -}) { - const key = () => props.controller.projectKey(props.project) - return ( - { - props.controller.setActive(key()) - props.controller.focusSearch() - }} - onSelect={() => props.onSelect(props.project)} - > - - {displayName(props.project)} - - - - - ) -} - -const projectActionClass = - "h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover" - -function ProjectAction(props: { - server?: string - controller: PromptProjectController - onSelect: (server?: string) => void -}) { - const key = () => props.controller.actionKey(props.server) - return ( - { - props.controller.setActive(key()) - props.controller.focusSearch() - }} - onSelect={() => props.onSelect(props.server)} - > - - - {props.controller.labels.add()} - - - ) -} - -function ServerAction(props: { server: { key: string; name: string }; onSelect: (server: string) => void }) { - return ( - props.onSelect(props.server.key)}> - {props.server.name} - - ) -} diff --git a/packages/app/src/components/prompt-workspace-selector.tsx b/packages/app/src/components/prompt-workspace-selector.tsx deleted file mode 100644 index a7a3537434..0000000000 --- a/packages/app/src/components/prompt-workspace-selector.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { For, Show } from "solid-js" -import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { Icon } from "@opencode-ai/ui/icon" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { getFilename } from "@opencode-ai/core/util/path" -import { useLanguage } from "@/context/language" - -export function PromptWorkspaceSelector(props: { - value: string - projectRoot: string - workspaces: string[] - branch?: string - onChange: (value: string) => void - onDone: () => void -}) { - const language = useLanguage() - let pending: string | undefined - const selected = () => (props.value === props.projectRoot ? "main" : props.value) - const icon = () => { - if (selected() === "main") return "monitor" - if (selected() === "create") return "workspace-new" - return "workspace" - } - const select = (value: string) => { - pending = value - } - const onOpenChange = (open: boolean) => { - if (open) return - const value = pending - pending = undefined - if (value) props.onChange(value) - props.onDone() - } - const label = () => { - if (selected() === "main") return language.t("session.new.workspace.triggerLocal") - if (props.value === "create") return language.t("workspace.new") - return getFilename(props.value) - } - - return ( - <> - - - - - {label()} - - - - - - {language.t("session.new.workspace.runIn")} - select("main")}> - - {language.t("session.new.workspace.local")} - - - - - select("create")}> - - {language.t("workspace.new")} - - - - - - 0}> - - - - - {language.t("session.new.workspace.existing")} - - - - - {(workspace) => ( - select(workspace)}> - - {getFilename(workspace)} - - - - - )} - - - - - - - - - - - ) -} - -export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) { - const language = useLanguage() - const label = () => { - if (props.noGit) return language.t("session.new.git.none") - return props.branch - } - - return ( - - {(value) => ( - <> - - -
- - {value()} -
-
- - )} -
- ) -} diff --git a/packages/app/src/components/server/server-row-menu.tsx b/packages/app/src/components/server/server-row-menu.tsx index 0a2920dec7..ece38ab49b 100644 --- a/packages/app/src/components/server/server-row-menu.tsx +++ b/packages/app/src/components/server/server-row-menu.tsx @@ -15,78 +15,41 @@ export const ServerRowMenu: Component<{ }> = (props) => { const language = useLanguage() const key = ServerConnection.key(props.server) - return ( - props.controller.setDefault(key)} - onRemoveDefault={() => props.controller.setDefault(null)} - onRemove={() => props.controller.handleRemove(key)} - open={props.open} - onOpenChange={props.onOpenChange} - /> - ) -} + const builtin = ServerConnection.builtin(props.server) + const isDefault = () => props.controller.defaultKey() === key -export function serverMenuLabels(language: ReturnType) { - return { - more: language.t("common.moreOptions"), - server: language.t("settings.section.server"), - edit: language.t("dialog.server.menu.edit"), - default: language.t("dialog.server.menu.default"), - defaultRemove: language.t("dialog.server.menu.defaultRemove"), - delete: language.t("dialog.server.menu.delete"), - } -} - -export const ServerRowMenuView: Component<{ - server: ServerConnection.Any - labels: ReturnType - canDefault: boolean - isDefault: boolean - onEdit: (server: ServerConnection.Http) => void - onSetDefault: () => void - onRemoveDefault: () => void - onRemove: () => void - open?: boolean - onOpenChange?: (open: boolean) => void -}> = (props) => { - const builtin = () => ServerConnection.builtin(props.server) - const httpServer = () => (props.server.type === "http" ? props.server : undefined) return ( - + } - aria-label={props.labels.more} + aria-label={language.t("common.moreOptions")} /> - {props.labels.server} + {language.t("settings.section.server")} { - const server = httpServer() - if (server) props.onEdit(server) - }} + disabled={builtin || props.server.type !== "http"} + onSelect={() => props.onEdit(props.server as ServerConnection.Http)} > - {props.labels.edit} + {language.t("dialog.server.menu.edit")} - - {props.labels.default} + + props.controller.setDefault(key)}> + {language.t("dialog.server.menu.default")} + - - {props.labels.defaultRemove} + + props.controller.setDefault(null)}> + {language.t("dialog.server.menu.defaultRemove")} + - - {props.labels.delete} + props.controller.handleRemove(key)}> + {language.t("dialog.server.menu.delete")} diff --git a/packages/app/src/components/server/server-row.tsx b/packages/app/src/components/server/server-row.tsx index 8060b0a708..d4f68d6306 100644 --- a/packages/app/src/components/server/server-row.tsx +++ b/packages/app/src/components/server/server-row.tsx @@ -117,7 +117,7 @@ export function ServerHealthIndicator(props: { health?: ServerHealth }) { return (
["placement"] -} - -function ContextTooltipRow(props: { name: JSX.Element; value: JSX.Element }) { - return ( -
- {props.name} - {props.value} -
- ) + placement?: TooltipProps["placement"] } function openSessionContext(args: { @@ -37,7 +22,7 @@ function openSessionContext(args: { layout: ReturnType tabs: ReturnType["tabs"]> }) { - args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button") + if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open() if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all") void args.tabs.open("context") args.tabs.setActive("context") @@ -48,22 +33,16 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const file = useFile() const layout = useLayout() const language = useLanguage() - const sdk = useSDK() - const settings = useSettings() - const providers = useProviders(() => sdk().directory) + const providers = useProviders() const { params, tabs, view } = useSessionLayout() - const isDesktop = createMediaQuery("(min-width: 768px)") const variant = createMemo(() => props.variant ?? "button") - const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default") const tabState = createSessionTabs({ tabs, pathFromTab: file.pathFromTab, normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), - fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id, }) const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : [])) - const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) const usd = createMemo( () => @@ -73,29 +52,21 @@ export function SessionContextUsage(props: SessionContextUsageProps) { }), ) - const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) + const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()])) + const context = createMemo(() => metrics().context) const cost = createMemo(() => { - return usd().format(info()?.cost ?? 0) + return usd().format(metrics().totalCost) }) - const contextVisible = createMemo(() => view().reviewPanel.opened() && tabState.activeTab() === "context") - const hasOtherTabs = createMemo(() => - tabs() - .all() - .some((tab) => tab !== "context" && tab !== "review"), - ) const openContext = () => { if (!params.id) return - const sessionView = view() - if (contextVisible()) { + if (tabState.activeTab() === "context") { tabs().close("context") - if (sessionView.reviewPanel.source() === "context-button" && !hasOtherTabs()) sessionView.reviewPanel.close() return } - openSessionContext({ - view: sessionView, + view: view(), layout, tabs: tabs(), }) @@ -103,54 +74,38 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const circle = () => (
- -
- ) - const circleV2 = () => ( -
- +
) const tooltipValue = () => ( -
- - - +
+ + {(ctx) => ( + <> +
+ {ctx().total.toLocaleString(language.intl())} + {language.t("context.usage.tokens")} +
+
+ {ctx().usage ?? 0}% + {language.t("context.usage.usage")} +
+ + )} +
+
+ {cost()} + {language.t("context.usage.cost")} +
) return ( - + {circle()} - - -
- + {(keybind) => ( - {keybind} + {keybind()} )} @@ -319,9 +316,9 @@ export function SessionHeader() { )} - + {(mount) => ( - + void } function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) { - const language = useLanguage() - return (
@@ -536,33 +530,20 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) { - - - {props.state.reviewLabel} - 0}> - - - - } - > - } - /> - - + + } + /> +
) } diff --git a/packages/app/src/components/session/session-new-design-view.tsx b/packages/app/src/components/session/session-new-design-view.tsx new file mode 100644 index 0000000000..6993b8f5a8 --- /dev/null +++ b/packages/app/src/components/session/session-new-design-view.tsx @@ -0,0 +1,16 @@ +import type { JSX } from "solid-js" +import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2" +import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" + +export function NewSessionDesignView(props: { children: JSX.Element }) { + return ( +
+
+
+ +
{props.children}
+
+
+
+ ) +} diff --git a/packages/app/src/components/session/session-sortable-tab-v2.tsx b/packages/app/src/components/session/session-sortable-tab-v2.tsx deleted file mode 100644 index e109e5d2ec..0000000000 --- a/packages/app/src/components/session/session-sortable-tab-v2.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { createMemo, Show } from "solid-js" -import type { JSX } from "solid-js" -import { useSortable } from "@dnd-kit/solid/sortable" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { Tabs } from "@opencode-ai/ui/tabs" -import { useFile } from "@/context/file" -import { useLanguage } from "@/context/language" -import { useCommand } from "@/context/command" -import { FileVisual } from "./session-sortable-tab" - -export function SortableTabV2(props: { - tab: string - index: () => number - temporary?: boolean - onTabClose: (tab: string) => void - onTabDoubleClick?: (tab: string) => void -}): JSX.Element { - const file = useFile() - const language = useLanguage() - const command = useCommand() - const closeTabKeybind = createMemo(() => command.keybindParts("tab.close")) - const sortable = useSortable({ - get id() { - return props.tab - }, - get index() { - return props.index() - }, - }) - const path = createMemo(() => file.pathFromTab(props.tab)) - const content = createMemo(() => { - const value = path() - if (!value) return - return - }) - return ( -
-
- - {language.t("common.closeTab")} - 0}> - - - - } - placement="bottom" - gutter={10} - > - props.onTabClose(props.tab)} - aria-label={language.t("common.closeTab")} - /> - - } - hideCloseButton - onMiddleClick={() => props.onTabClose(props.tab)} - onDblClick={() => props.onTabDoubleClick?.(props.tab)} - > - {(value) => value()} - -
-
- ) -} diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx index d78f392941..f04228ca66 100644 --- a/packages/app/src/components/session/session-sortable-tab.tsx +++ b/packages/app/src/components/session/session-sortable-tab.tsx @@ -10,7 +10,7 @@ import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { useCommand } from "@/context/command" -export function FileVisual(props: { path: string; active?: boolean; temporary?: boolean }): JSX.Element { +export function FileVisual(props: { path: string; active?: boolean }): JSX.Element { return (
- - {getFilename(props.path)} - + {getFilename(props.path)}
) } -export function SortableTab(props: { - tab: string - temporary?: boolean - onTabClose: (tab: string) => void - onTabDoubleClick?: (tab: string) => void -}): JSX.Element { +export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element { const file = useFile() const language = useLanguage() const command = useCommand() @@ -43,7 +36,7 @@ export function SortableTab(props: { const content = createMemo(() => { const value = path() if (!value) return - return + return }) return (
@@ -68,7 +61,6 @@ export function SortableTab(props: { } hideCloseButton onMiddleClick={() => props.onTabClose(props.tab)} - onDblClick={() => props.onTabDoubleClick?.(props.tab)} > {(value) => value()} diff --git a/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx b/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx deleted file mode 100644 index a27b29668e..0000000000 --- a/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import type { JSX } from "solid-js" -import { Show, createEffect, onCleanup } from "solid-js" -import { createStore } from "solid-js/store" -import { useSortable } from "@dnd-kit/solid/sortable" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { Tabs } from "@opencode-ai/ui/tabs" -import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Icon } from "@opencode-ai/ui/icon" -import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" -import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title" -import { useTerminal, type LocalPTY } from "@/context/terminal" -import { useLanguage } from "@/context/language" -import { focusTerminalById } from "@/pages/session/helpers" - -export function SortableTerminalTabV2(props: { - terminal: LocalPTY - index: () => number - newLayout: boolean - onClose?: () => void -}): JSX.Element { - const terminal = useTerminal() - const language = useLanguage() - const sortable = useSortable({ - get id() { - return props.terminal.id - }, - get index() { - return props.index() - }, - }) - const [store, setStore] = createStore({ - editing: false, - title: props.terminal.title, - menuOpen: false, - menuPosition: { x: 0, y: 0 }, - blurEnabled: false, - }) - let input: HTMLInputElement | undefined - let blurFrame: number | undefined - let editRequested = false - - const isDefaultTitle = () => { - const number = props.terminal.titleNumber - if (!Number.isFinite(number) || number <= 0) return false - return isDefaultTerminalTitle(props.terminal.title, number) - } - - const label = () => { - language.locale() - if (props.terminal.title && !isDefaultTitle()) return props.terminal.title - - const number = props.terminal.titleNumber - if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number }) - if (props.terminal.title) return props.terminal.title - return language.t("terminal.title") - } - - const close = () => { - const count = terminal.all().length - void terminal.close(props.terminal.id) - if (count === 1) { - props.onClose?.() - } - } - - const focus = () => { - if (store.editing) return - terminal.requestFocus(props.terminal.id) - terminal.open(props.terminal.id) - if (document.activeElement instanceof HTMLElement) document.activeElement.blur() - focusTerminalById(props.terminal.id) - const input = document.getElementById(`terminal-wrapper-${props.terminal.id}`)?.querySelector("textarea") - if (input === document.activeElement) terminal.consumeFocus(props.terminal.id) - } - - const edit = (e?: Event) => { - if (e) { - e.stopPropagation() - e.preventDefault() - } - - setStore("blurEnabled", false) - setStore("title", props.terminal.title) - setStore("editing", true) - } - - const save = () => { - if (!store.blurEnabled) return - - const value = store.title.trim() - if (value && value !== props.terminal.title) { - terminal.update({ id: props.terminal.id, title: value }) - } - setStore("editing", false) - } - - const keydown = (e: KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault() - save() - return - } - if (e.key === "Escape") { - e.preventDefault() - setStore("editing", false) - } - } - - const menu = (e: MouseEvent) => { - e.preventDefault() - setStore("menuPosition", { x: e.clientX, y: e.clientY }) - setStore("menuOpen", true) - } - - createEffect(() => { - if (!store.editing) return - if (!input) return - input.focus() - input.select() - if (blurFrame !== undefined) cancelAnimationFrame(blurFrame) - blurFrame = requestAnimationFrame(() => { - blurFrame = undefined - setStore("blurEnabled", true) - }) - }) - - onCleanup(() => { - if (blurFrame === undefined) return - cancelAnimationFrame(blurFrame) - }) - - return ( -
- - e.preventDefault()} - onContextMenu={menu} - class="!shadow-none" - classes={{ - button: "border-0 outline-none focus:outline-none focus-visible:outline-none !shadow-none !ring-0", - }} - closeButton={ - { - e.stopPropagation() - close() - }} - aria-label={language.t("terminal.close")} - /> - } - > - - {label()} - - - -
- setStore("title", e.currentTarget.value)} - onBlur={save} - onKeyDown={keydown} - onMouseDown={(e) => e.stopPropagation()} - class="bg-transparent border-none outline-none text-sm min-w-0 flex-1" - /> -
-
- setStore("menuOpen", open)}> - - { - if (!editRequested) return - e.preventDefault() - editRequested = false - requestAnimationFrame(() => edit()) - }} - > - (editRequested = true)}> - - {language.t("common.rename")} - - - - {language.t("common.close")} - - - - -
- } - > - - - { - // Switch on mousedown to shave the press-release delay off tab switches. - if (e.button !== 0) return - if (store.editing) return - focus() - }} - onClick={(e) => { - // Mouse navigation already happened on mousedown; detail 0 means keyboard activation. - if (e.detail > 0) return - focus() - }} - closeButton={ - { - e.stopPropagation() - close() - }} - aria-label={language.t("terminal.close")} - /> - } - hideCloseButton - onMiddleClick={close} - > - - {label()} - - - -
- setStore("title", e.currentTarget.value)} - onBlur={save} - onKeyDown={keydown} - onMouseDown={(e) => e.stopPropagation()} - class="bg-transparent border-none outline-none min-w-0 flex-1 p-0 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:440] [font-variation-settings:'slnt'_0] [font-variant-numeric:tabular-nums]" - /> -
-
-
- - { - if (!editRequested) return - e.preventDefault() - editRequested = false - requestAnimationFrame(() => edit()) - }} - > - (editRequested = true)}>{language.t("common.rename")} - {language.t("common.close")} - - -
- -
- ) -} diff --git a/packages/app/src/components/settings-dialog.tsx b/packages/app/src/components/settings-dialog.tsx deleted file mode 100644 index 46dd89ade8..0000000000 --- a/packages/app/src/components/settings-dialog.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useParams } from "@solidjs/router" -import { onCleanup } from "solid-js" -import { useCommand } from "@/context/command" -import { useLanguage } from "@/context/language" -import { useDialog } from "@opencode-ai/ui/context/dialog" - -export function useSettingsDialog(defaultValue?: string) { - const dialog = useDialog() - const params = useParams<{ id?: string }>() - let run = 0 - let dead = false - - onCleanup(() => { - dead = true - }) - - return () => { - const current = ++run - const sessionID = params.id - void import("@/components/settings-v2").then((module) => { - if (dead || run !== current) return - void dialog.show(() => ) - }) - } -} - -export function useSettingsCommand() { - const command = useCommand() - const language = useLanguage() - const show = useSettingsDialog() - - command.register("settings", () => [ - { - id: "settings.open", - title: language.t("command.settings.open"), - category: language.t("command.category.settings"), - keybind: "mod+comma", - onSelect: show, - }, - ]) - - return show -} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 8adaa1f0fd..1be3ffc513 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -5,7 +5,6 @@ import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" import { TextField } from "@opencode-ai/ui/text-field" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { Tag } from "@opencode-ai/ui/v2/badge-v2" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useParams } from "@solidjs/router" @@ -29,7 +28,7 @@ import { } from "@/context/settings" import { decode64 } from "@/utils/base64" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" -import { ExternalLink } from "./external-link" +import { Link } from "./link" import { SettingsList } from "./settings-list" let demoSoundState = { @@ -127,14 +126,11 @@ export const SettingsGeneral: Component = () => { const serverSdk = useServerSDK() const [shells] = createResource( - async () => { - const sdk = serverSdk() - if ((await sdk.protocol) === "v1") { - return (await sdk.client.pty.shells()).data ?? [] - } - // return (await sdk.api.pty.shells()).data - return [] as ShellOption[] - }, + () => + serverSdk() + .client.pty.shells() + .then((res) => res.data ?? []) + .catch(() => [] as ShellOption[]), { initialValue: [] as ShellOption[] }, ) @@ -252,50 +248,6 @@ export const SettingsGeneral: Component = () => { triggerVariant: "settings" as const, }) - const InterfaceSection = () => ( -
- - - {language.t("settings.general.row.newInterface.title")} - {language.t("settings.general.row.newInterface.badge")} - - } - description={language.t("settings.general.row.newInterface.description")} - > -
- { - settings.general.setNewLayoutDesigns(checked) - if (!checked) return - void import("@/components/settings-v2").then((module) => { - void dialog.show(() => ) - }) - }} - /> -
-
-
-
- ) - - const InterfaceNoticeSection = () => ( -
- - - - - -
- ) - const GeneralSection = () => (
@@ -382,6 +334,36 @@ export const SettingsGeneral: Component = () => { />
+ + +
+ settings.general.setShowSessionProgressBar(checked)} + /> +
+
+ + +
+ { + settings.general.setNewLayoutDesigns(checked) + if (!checked) return + void import("@/components/settings-v2").then((module) => { + dialog.show(() => ) + }) + }} + /> +
+
) @@ -470,6 +452,11 @@ export const SettingsGeneral: Component = () => { value={(o) => o.value} label={(o) => o.label} onSelect={(option) => option && theme.setColorScheme(option.value)} + onHighlight={(option) => { + if (!option) return + theme.previewColorScheme(option.value) + return () => theme.cancelPreview() + }} variant="secondary" size="small" triggerVariant="settings" @@ -482,7 +469,7 @@ export const SettingsGeneral: Component = () => { description={ <> {language.t("settings.general.row.theme.description")}{" "} - {language.t("common.learnMore")} + {language.t("common.learnMore")} } > @@ -496,6 +483,11 @@ export const SettingsGeneral: Component = () => { if (!option) return theme.setTheme(option.id) }} + onHighlight={(option) => { + if (!option) return + theme.previewTheme(option.id) + return () => theme.cancelPreview() + }} variant="secondary" size="small" triggerVariant="settings" @@ -748,14 +740,6 @@ export const SettingsGeneral: Component = () => {
- - - - - - - - diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index ce74be4586..98f6c9ffa0 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -5,21 +5,28 @@ import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { TextField } from "@opencode-ai/ui/text-field" -import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" -import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" -import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { showToast } from "@/utils/toast" import fuzzysort from "fuzzysort" -import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command" +import { formatKeybind, parseKeybind, useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { SettingsList } from "./settings-list" -import { SettingsListV2 } from "./settings-v2/parts/list" +const ButtonV2 = lazy(() => import("@opencode-ai/ui/v2/button-v2").then((module) => ({ default: module.ButtonV2 }))) const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({ default: module.Icon }))) +const IconButtonV2 = lazy(() => + import("@opencode-ai/ui/v2/icon-button-v2").then((module) => ({ default: module.IconButtonV2 })), +) +const TextInputV2 = lazy(() => + import("@opencode-ai/ui/v2/text-input-v2").then((module) => ({ default: module.TextInputV2 })), +) +const SettingsListV2 = lazy(() => + import("./settings-v2/parts/list").then((module) => ({ default: module.SettingsListV2 })), +) const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) const PALETTE_ID = "command.palette" +const DEFAULT_PALETTE_KEYBIND = "mod+shift+p" type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt" @@ -30,8 +37,6 @@ type KeybindMeta = { type KeybindMap = Record type CommandContext = ReturnType -type LanguageContext = ReturnType -type SettingsContext = ReturnType const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"] @@ -124,7 +129,7 @@ function keybinds(value: unknown): KeybindMap { return value as KeybindMap } -function listFor(command: Pick, map: KeybindMap, palette: string) { +function listFor(command: CommandContext, map: KeybindMap, palette: string) { const out = new Map() out.set(PALETTE_ID, { title: palette, group: "General" }) @@ -264,274 +269,7 @@ function useKeyCapture(input: { }) } -export function createKeybindSettingsController( - input: { - command: Pick - settings: { - current: { keybinds: unknown } - keybinds: Pick - } - target?: Document - notify?: (toast: { title: string; description: string }) => void - }, - language: Pick = useLanguage(), -) { - const [store, setStore] = createStore({ active: null as string | null }) - const overrides = createMemo(() => keybinds(input.settings.current.keybinds)) - const list = createMemo(() => { - language.locale() - return listFor(input.command, overrides(), language.t("command.palette")) - }) - const grouped = createMemo(() => groupedFor(list())) - const title = (id: string) => list().get(id)?.title ?? "" - const effective = (id: string) => { - if (id === PALETTE_ID) return input.settings.keybinds.get(id) ?? DEFAULT_PALETTE_KEYBIND - - const custom = input.settings.keybinds.get(id) - if (typeof custom === "string") return custom - - const live = input.command.options.find((item) => item.id === id) - if (live?.keybind) return live.keybind - return input.command.catalog.find((item) => item.id === id)?.keybind - } - const used = createMemo(() => { - const value = new Map() - - for (const id of list().keys()) { - for (const signature of signatures(effective(id))) { - const items = value.get(signature) - if (items) { - items.push({ id, title: title(id) }) - continue - } - value.set(signature, [{ id, title: title(id) }]) - } - } - - return value - }) - const stop = () => { - if (!store.active) return - setStore("active", null) - input.command.keybinds(true) - } - const toggle = (id: string) => { - if (store.active === id) { - stop() - return - } - if (store.active) stop() - setStore("active", id) - input.command.keybinds(false) - } - const notify = input.notify ?? ((toast: { title: string; description: string }) => showToast(toast)) - - const handle = (event: KeyboardEvent) => { - const id = store.active - if (!id) return - - event.preventDefault() - event.stopPropagation() - event.stopImmediatePropagation() - - if (event.key === "Escape") { - stop() - return - } - - const clear = - (event.key === "Backspace" || event.key === "Delete") && - !event.ctrlKey && - !event.metaKey && - !event.altKey && - !event.shiftKey - if (clear) { - input.settings.keybinds.set(id, "none") - stop() - return - } - - const next = recordKeybind(event) - if (!next) return - - const conflicts = new Map() - for (const signature of signatures(next)) { - for (const item of used().get(signature) ?? []) { - if (item.id === id) continue - conflicts.set(item.id, item.title) - } - } - - if (conflicts.size > 0) { - notify({ - title: language.t("settings.shortcuts.conflict.title"), - description: language.t("settings.shortcuts.conflict.description", { - keybind: formatKeybind(next, language.t), - titles: [...conflicts.values()].join(", "), - }), - }) - return - } - - input.settings.keybinds.set(id, next) - stop() - } - - const target = input.target ?? (typeof document === "object" ? document : undefined) - if (target) makeEventListener(target, "keydown", handle, { capture: true }) - - onCleanup(() => { - if (store.active) input.command.keybinds(true) - }) - - return { - catalog: { - groups: GROUPS, - filtered: (query: string) => - filteredFor(query, list(), grouped(), (id) => formatKeybind(effective(id) ?? "", language.t)), - title, - keybind: (id: string) => formatKeybind(effective(id) ?? "", language.t), - }, - capture: { - active: () => store.active, - toggle, - }, - settings: { - hasOverrides: () => Object.values(overrides()).some((value) => typeof value === "string"), - reset: () => { - stop() - input.settings.keybinds.resetAll() - notify({ - title: language.t("settings.shortcuts.reset.toast.title"), - description: language.t("settings.shortcuts.reset.toast.description"), - }) - }, - }, - } -} - -function SettingsKeybindsV2() { - const command = useCommand() - const settings = useSettings() - const controller = createKeybindSettingsController({ - command, - settings, - }) - - return ( - - ) -} - -function SettingsKeybindsV2View(props: { - groups: KeybindGroup[] - filtered: (query: string) => Map - title: (id: string) => string - keybind: (id: string) => string - active: () => string | null - onCapture: (id: string) => void - hasOverrides: () => boolean - onReset: () => void -}) { - const language = useLanguage() - const [store, setStore] = createStore({ filter: "" }) - const filtered = createMemo(() => props.filtered(store.filter)) - const hasResults = createMemo(() => props.groups.some((group) => (filtered().get(group)?.length ?? 0) > 0)) - - return ( - <> -
-
-

{language.t("settings.shortcuts.title")}

- - {language.t("settings.shortcuts.reset.button")} - -
- -
-
-
- - {(group) => ( - 0}> -
-

{language.t(groupKey[group])}

- - - {(id) => ( -
- {props.title(id)} - -
- )} -
-
-
-
- )} -
- -
- {language.t("settings.shortcuts.search.empty")} - "{store.filter}" -
-
-
-
- - ) -} - export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => { - if (props.v2) return - const command = useCommand() const language = useLanguage() const settings = useSettings() @@ -745,37 +483,78 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => { ) return ( -
-
-
-
-

{language.t("settings.shortcuts.title")}

- -
+ +
+
+
+

{language.t("settings.shortcuts.title")}

+ +
-
- - + + setStore("filter", v)} + placeholder={language.t("settings.shortcuts.search.placeholder")} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + class="flex-1" + /> + + setStore("filter", "")} /> + +
+
+
+ {groups} +
+ } + > + <> +
+
+

{language.t("settings.shortcuts.title")}

+ + {language.t("settings.shortcuts.reset.button")} + +
+
-
- {groups} -
+
{groups}
+ + ) } diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 080e6c517b..24e7a60104 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -6,9 +6,10 @@ import { showToast } from "@/utils/toast" import { popularProviders, useProviders } from "@/hooks/use-providers" import { createMemo, type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" -import { useServerProtocol, useServerSDK } from "@/context/server-sdk" +import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" -import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider" +import { DialogConnectProvider } from "./dialog-connect-provider" +import { DialogSelectProvider } from "./dialog-select-provider" import { DialogCustomProvider } from "./dialog-custom-provider" import { SettingsList } from "./settings-list" import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker" @@ -27,27 +28,20 @@ const PROVIDER_NOTES = [ { match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" }, ] as const -export const SettingsProviders: Component<{ onBack?: () => void }> = (props) => { +export const SettingsProviders: Component = () => { return ( - + ) } -const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => { +const SettingsProvidersContent: Component = () => { const dialog = useDialog() const language = useLanguage() const serverSDK = useServerSDK() - const protocol = useServerProtocol() const serverSync = useServerSync() - const providers = useProviders(() => undefined) - const providerConnect = useProviderConnectController({ onBack: props.onBack }) - - const connect = (provider?: string) => { - providerConnect.select(provider) - void dialog.show(() => ) - } + const providers = useProviders() const connected = createMemo(() => { return providers @@ -84,8 +78,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => return language.t("settings.providers.tag.other") } - const canDisconnect = (item: ProviderItem) => - source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id)) + const canDisconnect = (item: ProviderItem) => source(item) !== "env" const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key @@ -98,7 +91,6 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => } const disableProvider = async (providerID: string, name: string) => { - if (protocol() !== "v1") return const before = serverSync().data.config.disabled_providers ?? [] const next = before.includes(providerID) ? before : [...before, providerID] serverSync().set("config", "disabled_providers", next) @@ -214,46 +206,53 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => {(key) => {language.t(key())}}
-
)} - -
-
-
- - {language.t("provider.custom.title")} - {language.t("settings.providers.tag.custom")} -
- - {language.t("settings.providers.custom.description")} - +
+
+
+ + {language.t("provider.custom.title")} + {language.t("settings.providers.tag.custom")}
- + + {language.t("settings.providers.custom.description")} +
- + +
diff --git a/packages/app/src/components/settings-server-picker.tsx b/packages/app/src/components/settings-server-picker.tsx index f3544c058a..294eea6180 100644 --- a/packages/app/src/components/settings-server-picker.tsx +++ b/packages/app/src/components/settings-server-picker.tsx @@ -26,7 +26,7 @@ export function SettingsServerScope(props: ParentProps) { function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnection.Any }>) { const global = useGlobal() - const serverCtx = () => global.ensureServerCtx(props.server) + const serverCtx = () => global.createServerCtx(props.server) return ( diff --git a/packages/app/src/components/settings-v2/dialog-server-v2.tsx b/packages/app/src/components/settings-v2/dialog-server-v2.tsx index 7113cdcf0a..bad1c9ab1a 100644 --- a/packages/app/src/components/settings-v2/dialog-server-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-server-v2.tsx @@ -1,6 +1,5 @@ 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 { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { useDialog } from "@opencode-ai/ui/context/dialog" import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js" @@ -53,12 +52,8 @@ export const DialogServerV2: Component<{ } return ( - - - {title()} - - - + +
@@ -120,7 +115,7 @@ export const DialogServerV2: Component<{
-
+
dialog.close()}> {language.t("common.cancel")} diff --git a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx index 4116f4a620..cfe6971b12 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -1,4 +1,4 @@ -import { Component, createMemo, createSignal, startTransition } from "solid-js" +import { Component } from "solid-js" import { Dialog } from "@opencode-ai/ui/v2/dialog-v2" import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" import { Icon } from "@opencode-ai/ui/icon" @@ -10,46 +10,14 @@ import { SettingsProvidersV2 } from "./providers" import { SettingsModelsV2 } from "./models" import "./settings-v2.css" import { SettingsServersV2 } from "./servers" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useLayout } from "@/context/layout" -import { useTabs } from "@/context/tabs" -import { useServerSync } from "@/context/server-sync" -export const DialogSettings: Component<{ - sessionID?: string - defaultValue?: string -}> = (props) => { +export const DialogSettings: Component = () => { const language = useLanguage() const platform = usePlatform() - const dialog = useDialog() - const layout = useLayout() - const tabs = useTabs() - const serverSync = useServerSync() - const [tab, setTab] = createSignal(props.defaultValue ?? "general") - const directory = createMemo(() => { - const route = layout.route() - if (route.type === "dir-new-sesssion") return route.dir - if (route.type === "draft") { - const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID) - return draft?.type === "draft" ? draft.directory : undefined - } - if (route.type === "session") return serverSync().session.get(route.sessionId)?.directory - return undefined - }) - - const showProviders = () => { - void dialog.show(() => ) - } return ( - void startTransition(() => setTab(value))} - class="settings-v2" - > +
@@ -94,7 +62,7 @@ export const DialogSettings: Component<{
- + @@ -103,7 +71,7 @@ export const DialogSettings: Component<{ - + diff --git a/packages/app/src/components/settings-v2/general-controller-behavior.ts b/packages/app/src/components/settings-v2/general-controller-behavior.ts deleted file mode 100644 index 64bd4c1187..0000000000 --- a/packages/app/src/components/settings-v2/general-controller-behavior.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { onCleanup } from "solid-js" - -export type ShellOption = { - path: string - name: string - acceptable: boolean -} - -export type ShellSelectOption = { - id: string - value: string - name: string - terminalOnly: boolean -} - -export function createShellOptions(input: { shells: ShellOption[]; current: string | undefined }) { - const counts = input.shells.reduce((result, shell) => { - result.set(shell.name, (result.get(shell.name) ?? 0) + 1) - return result - }, new Map()) - const options: ShellSelectOption[] = [ - { id: "auto", value: "", name: "", terminalOnly: false }, - ...input.shells.map((shell) => { - const ambiguous = (counts.get(shell.name) ?? 0) > 1 - const name = ambiguous ? shell.path : shell.name - return { - id: shell.path, - value: ambiguous ? shell.path : shell.name, - name, - terminalOnly: !shell.acceptable, - } - }), - ] - if (input.current && !options.some((option) => option.value === input.current)) { - options.push({ id: input.current, value: input.current, name: input.current, terminalOnly: false }) - } - return options -} - -export function createSoundPreviewController(player: (id: string | undefined) => Promise<(() => void) | undefined>) { - let cleanup: (() => void) | undefined - let timeout: ReturnType | undefined - let run = 0 - - const stop = () => { - run += 1 - cleanup?.() - clearTimeout(timeout) - cleanup = undefined - timeout = undefined - } - const play = (id: string | undefined) => { - stop() - if (!id) return - const current = ++run - timeout = setTimeout(() => { - timeout = undefined - void player(id).then((next) => { - if (run === current) { - cleanup = next - return - } - next?.() - }) - }, 100) - } - - onCleanup(stop) - return { play, stop } -} diff --git a/packages/app/src/components/settings-v2/general-controllers.test.ts b/packages/app/src/components/settings-v2/general-controllers.test.ts deleted file mode 100644 index 09c8d21bd5..0000000000 --- a/packages/app/src/components/settings-v2/general-controllers.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, test, vi } from "bun:test" -import { createRoot } from "solid-js" -import { createShellOptions, createSoundPreviewController } from "./general-controller-behavior" - -describe("settings v2 controllers", () => { - test("normalizes shell names and preserves an unavailable configured shell", () => { - expect( - createShellOptions({ - shells: [ - { path: "/bin/bash", name: "bash", acceptable: true }, - { path: "/opt/bash", name: "bash", acceptable: false }, - { path: "/bin/zsh", name: "zsh", acceptable: true }, - ], - current: "fish", - }), - ).toEqual([ - { id: "auto", value: "", name: "", terminalOnly: false }, - { id: "/bin/bash", value: "/bin/bash", name: "/bin/bash", terminalOnly: false }, - { id: "/opt/bash", value: "/opt/bash", name: "/opt/bash", terminalOnly: true }, - { id: "/bin/zsh", value: "zsh", name: "zsh", terminalOnly: false }, - { id: "fish", value: "fish", name: "fish", terminalOnly: false }, - ]) - }) - - test("debounces previews and stops owned audio on disposal", async () => { - vi.useFakeTimers() - try { - const played: string[] = [] - const stopped: string[] = [] - const owned = createRoot((dispose) => ({ - dispose, - preview: createSoundPreviewController(async (id) => { - played.push(id ?? "") - return () => stopped.push(id ?? "") - }), - })) - - owned.preview.play("first") - vi.advanceTimersByTime(99) - expect(played).toEqual([]) - - owned.preview.play("second") - vi.advanceTimersByTime(100) - await Promise.resolve() - expect(played).toEqual(["second"]) - - owned.dispose() - expect(stopped).toEqual(["second"]) - } finally { - vi.useRealTimers() - } - }) -}) diff --git a/packages/app/src/components/settings-v2/general-controllers.ts b/packages/app/src/components/settings-v2/general-controllers.ts deleted file mode 100644 index ae77fa332f..0000000000 --- a/packages/app/src/components/settings-v2/general-controllers.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { createMemo, createResource, onMount, type Accessor } from "solid-js" -import type { ColorScheme } from "@opencode-ai/ui/theme/context" -import { useTheme } from "@opencode-ai/ui/theme/context" -import { usePermission } from "@/context/permission" -import { useServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" -import { - monoDefault, - monoFontFamily, - monoInput, - sansDefault, - sansFontFamily, - sansInput, - terminalDefault, - terminalFontFamily, - terminalInput, - useSettings, -} from "@/context/settings" -import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" -import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior" - -export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior" -export type { ShellOption, ShellSelectOption } from "./general-controller-behavior" - -export function createPermissionScopeController(sessionID: Accessor) { - const permission = usePermission() - const serverSync = useServerSync() - const directory = createMemo(() => { - const id = sessionID() - if (!id) return undefined - return serverSync().session.lineage.peek(id)?.session.directory - }) - - return { - accepting: createMemo(() => { - const id = sessionID() - const dir = directory() - if (!id || !dir) return false - return permission.isAutoAccepting(id, dir) - }), - enabled: createMemo(() => !!directory()), - set: (checked: boolean) => { - const id = sessionID() - const dir = directory() - if (!id || !dir) return - if (checked) return permission.enableAutoAccept(id, dir) - permission.disableAutoAccept(id, dir) - }, - } -} - -export function createShellSettingsController() { - const serverSdk = useServerSDK() - const serverSync = useServerSync() - const [shells] = createResource( - async () => { - const sdk = serverSdk() - if ((await sdk.protocol) === "v1") return (await sdk.client.pty.shells()).data ?? [] - return [] as ShellOption[] - }, - { initialValue: [] as ShellOption[] }, - ) - const current = createMemo(() => serverSync().data.config.shell ?? "") - - return { - shells: () => shells.latest, - current, - select: (value: string) => { - if (value === current()) return - void serverSync().updateConfig({ shell: value }) - }, - } -} - -export function createAppearanceSettingsController() { - const settings = useSettings() - const theme = useTheme() - const themes = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) - - onMount(() => void theme.loadThemes()) - - return { - scheme: { - current: theme.colorScheme, - select: (value: ColorScheme) => theme.setColorScheme(value), - }, - theme: { - options: themes, - current: createMemo(() => themes().find((option) => option.id === theme.themeId())), - select: (option: { id: string } | null) => option && theme.setTheme(option.id), - }, - fonts: { - ui: createMemo(() => ({ - value: sansInput(settings.appearance.uiFont()), - family: sansFontFamily(settings.appearance.uiFont()), - placeholder: sansDefault, - })), - code: createMemo(() => ({ - value: monoInput(settings.appearance.font()), - family: monoFontFamily(settings.appearance.font()), - placeholder: monoDefault, - })), - terminal: createMemo(() => ({ - value: terminalInput(settings.appearance.terminalFont()), - family: terminalFontFamily(settings.appearance.terminalFont()), - placeholder: terminalDefault, - })), - setUI: (value: string) => settings.appearance.setUIFont(value), - setCode: (value: string) => settings.appearance.setFont(value), - setTerminal: (value: string) => settings.appearance.setTerminalFont(value), - }, - } -} - -const noneSound = { id: "none", label: "sound.option.none" } as const -export const soundOptions = [noneSound, ...SOUND_OPTIONS] -export type SoundSelectOption = (typeof soundOptions)[number] - -export function createSoundSettingsController() { - const settings = useSettings() - const preview = createSoundPreviewController(playSoundById) - const channel = ( - enabled: Accessor, - current: Accessor, - setEnabled: (value: boolean) => void, - set: (id: string) => void, - ) => ({ - current: createMemo(() => - enabled() ? (soundOptions.find((option) => option.id === current()) ?? noneSound) : noneSound, - ), - highlight: (option: SoundSelectOption | undefined) => { - if (!option) return - preview.play(option.id === "none" ? undefined : option.id) - }, - select: (option: SoundSelectOption | null) => { - if (!option) return - if (option.id === "none") { - setEnabled(false) - preview.stop() - return - } - setEnabled(true) - set(option.id) - preview.play(option.id) - }, - }) - - return { - agent: channel( - settings.sounds.agentEnabled, - settings.sounds.agent, - (value) => settings.sounds.setAgentEnabled(value), - (id) => settings.sounds.setAgent(id), - ), - permissions: channel( - settings.sounds.permissionsEnabled, - settings.sounds.permissions, - (value) => settings.sounds.setPermissionsEnabled(value), - (id) => settings.sounds.setPermissions(id), - ), - errors: channel( - settings.sounds.errorsEnabled, - settings.sounds.errors, - (value) => settings.sounds.setErrorsEnabled(value), - (id) => settings.sounds.setErrors(id), - ), - } -} - -export type PermissionScopeController = ReturnType -export type ShellSettingsController = ReturnType -export type AppearanceSettingsController = ReturnType -export type SoundSettingsController = ReturnType diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index b41da107ed..93caa64673 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -1,297 +1,182 @@ -import { Component, Show, createMemo, createResource } from "solid-js" -import { createMediaQuery } from "@solid-primitives/media" +import { Component, Show, createMemo, createResource, onMount } from "solid-js" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" import { Switch } from "@opencode-ai/ui/v2/switch-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useParams } from "@solidjs/router" import { useLanguage } from "@/context/language" +import { usePermission } from "@/context/permission" import { usePlatform } from "@/context/platform" +import { useServerSync } from "@/context/server-sync" +import { useServerSDK } from "@/context/server-sdk" import { useUpdaterAction } from "../updater-action" -import { useSettings } from "@/context/settings" -import { ExternalLink } from "../external-link" +import { + monoDefault, + monoFontFamily, + monoInput, + sansDefault, + sansFontFamily, + sansInput, + terminalDefault, + terminalFontFamily, + terminalInput, + useSettings, +} from "@/context/settings" +import { decode64 } from "@/utils/base64" +import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" +import { Link } from "../link" import { SettingsListV2 } from "./parts/list" import { SettingsRowV2 } from "./parts/row" -import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition" -import { - createAppearanceSettingsController, - createPermissionScopeController, - createShellOptions, - createShellSettingsController, - createSoundSettingsController, - soundOptions, - type AppearanceSettingsController, - type PermissionScopeController, - type ShellSettingsController, - type SoundSettingsController, -} from "./general-controllers" import "./settings-v2.css" -const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"] -const fontSettings = { - ui: { - action: "settings-ui-font", - title: "settings.general.row.uiFont.title", - description: "settings.general.row.uiFont.description", - font: "ui", - input: "setUI", - }, - code: { - action: "settings-code-font", - title: "settings.general.row.font.title", - description: "settings.general.row.font.description", - font: "code", - input: "setCode", - }, - terminal: { - action: "settings-terminal-font", - title: "settings.general.row.terminalFont.title", - description: "settings.general.row.terminalFont.description", - font: "terminal", - input: "setTerminal", - }, -} as const -const soundSettings = { - agent: { - action: "settings-sounds-agent", - title: "settings.general.sounds.agent.title", - description: "settings.general.sounds.agent.description", - }, - permissions: { - action: "settings-sounds-permissions", - title: "settings.general.sounds.permissions.title", - description: "settings.general.sounds.permissions.description", - }, - errors: { - action: "settings-sounds-errors", - title: "settings.general.sounds.errors.title", - description: "settings.general.sounds.errors.description", - }, -} as const - -const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => { - const language = useLanguage() - return ( - -
- -
-
- ) +let demoSoundState = { + cleanup: undefined as (() => void) | undefined, + timeout: undefined as NodeJS.Timeout | undefined, + run: 0, } -const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => { - const language = useLanguage() - const options = createMemo(() => - createShellOptions({ - shells: props.controller.shells(), - current: props.controller.current(), - }), - ) - return ( - - option.value === props.controller.current()) ?? options()[0]} - placement="bottom-end" - gutter={6} - value={(option) => option.id} - label={(option) => { - if (option.id === "auto") return language.t("settings.general.row.shell.autoDefault") - if (!option.terminalOnly) return option.name - return `${option.name} (${language.t("settings.general.row.shell.terminalOnly")})` - }} - onSelect={(option) => option && props.controller.select(option.value)} - /> - - ) +type ThemeOption = { + id: string + name: string } -const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => { - const language = useLanguage() - return ( -
-

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

- - - option === props.controller.scheme.current())} - placement="bottom-end" - gutter={6} - label={(option) => { - if (option === "system") return language.t("theme.scheme.system") - if (option === "light") return language.t("theme.scheme.light") - return language.t("theme.scheme.dark") - }} - onSelect={(option) => option && props.controller.scheme.select(option)} - /> - - - - {language.t("settings.general.row.theme.description")}{" "} - - {language.t("common.learnMore")} - - - } - > - option.id} - label={(option) => option.name} - onSelect={props.controller.theme.select} - /> - - - - - - -
- ) +type ShellOption = { + path: string + name: string + acceptable: boolean } -const FontSetting: Component<{ - kind: "ui" | "code" | "terminal" - fonts: AppearanceSettingsController["fonts"] -}> = (props) => { - const language = useLanguage() - const config = () => fontSettings[props.kind] - return ( - -
- props.fonts[config().input](event.currentTarget.value)} - placeholder={props.fonts[config().font]().placeholder} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - aria-label={language.t(config().title)} - style={{ "font-family": props.fonts[config().font]().family }} - /> -
-
- ) +type ShellSelectOption = { + id: string + value: string + label: string } -const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => { - const language = useLanguage() - return ( -
-

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

- - - - - -
- ) +// To prevent audio from overlapping/playing very quickly when navigating the settings menus, +// delay the playback by 100ms during quick selection changes and pause existing sounds. +const stopDemoSound = () => { + demoSoundState.run += 1 + if (demoSoundState.cleanup) { + demoSoundState.cleanup() + } + clearTimeout(demoSoundState.timeout) + demoSoundState.cleanup = undefined } -const SoundSetting: Component<{ - kind: "agent" | "permissions" | "errors" - channel: SoundSettingsController["agent"] -}> = (props) => { - const language = useLanguage() - const config = () => soundSettings[props.kind] - return ( - - option.id} - label={(option) => language.t(option.label)} - onHighlight={props.channel.highlight} - onSelect={props.channel.select} - placement="bottom-end" - gutter={6} - /> - - ) +const playDemoSound = (id: string | undefined) => { + stopDemoSound() + if (!id) return + + const run = ++demoSoundState.run + demoSoundState.timeout = setTimeout(() => { + void playSoundById(id).then((cleanup) => { + if (demoSoundState.run !== run) { + cleanup?.() + return + } + demoSoundState.cleanup = cleanup + }) + }, 100) } -const LanguageSetting = () => { - const language = useLanguage() - const options = createMemo(() => - language.locales.map((locale) => ({ - value: locale, - label: language.label(locale), - })), - ) - return ( - - option.value === language.locale())} - value={(option) => option.value} - label={(option) => option.label} - onSelect={(option) => option && language.setLocale(option.value)} - /> - - ) -} - -export const SettingsGeneralV2: Component<{ - sessionID?: string -}> = (props) => { +export const SettingsGeneralV2: Component = () => { + const theme = useTheme() const language = useLanguage() + const permission = usePermission() const platform = usePlatform() const dialog = useDialog() + const params = useParams() const settings = useSettings() - const mobile = createMediaQuery("(max-width: 767px)") + const updater = useUpdaterAction() - const permissionScope = createPermissionScopeController(() => props.sessionID) - const shell = createShellSettingsController() - const appearance = createAppearanceSettingsController() - const sounds = createSoundSettingsController() + + const dir = createMemo(() => decode64(params.dir)) + const accepting = createMemo(() => { + const value = dir() + if (!value) return false + if (!params.id) return permission.isAutoAcceptingDirectory(value) + return permission.isAutoAccepting(params.id, value) + }) + + const toggleAccept = (checked: boolean) => { + const value = dir() + if (!value) return + + if (!params.id) { + if (permission.isAutoAcceptingDirectory(value) === checked) return + permission.toggleAutoAcceptDirectory(value) + return + } + + if (checked) { + permission.enableAutoAccept(params.id, value) + return + } + + permission.disableAutoAccept(params.id, value) + } const desktop = createMemo(() => platform.platform === "desktop") + const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) + + const serverSync = useServerSync() + const serverSdk = useServerSDK() + + const [shells] = createResource( + () => + serverSdk() + .client.pty.shells() + .then((res) => res.data ?? []) + .catch(() => [] as ShellOption[]), + { initialValue: [] as ShellOption[] }, + ) + const [pinchZoom, { mutate: setPinchZoom }] = createResource( - () => desktop() && "getPinchZoomEnabled" in platform, + () => (desktop() && platform.getPinchZoomEnabled ? true : false), () => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false), { initialValue: false }, ) + onMount(() => { + void theme.loadThemes() + }) + + const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") } + const currentShell = createMemo(() => serverSync().data.config.shell ?? "") + + const shellOptions = createMemo(() => { + const list = shells.latest + const current = serverSync().data.config.shell + + const nameCounts = new Map() + for (const s of list) { + nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1) + } + + const options = [ + autoOption, + ...list.map((s) => { + const ambiguousName = (nameCounts.get(s.name) || 0) > 1 + const text = ambiguousName ? s.path : s.name + const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})` + return { + id: s.path, + // Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH. + value: ambiguousName ? s.path : s.name, + label, + } + }), + ] + + if (current && !options.some((o) => o.value === current)) { + options.push({ id: current, value: current, label: current }) + } + + return options + }) + const onPinchZoomChange = (checked: boolean) => { setPinchZoom(checked) const update = platform.setPinchZoomEnabled?.(checked) @@ -299,39 +184,101 @@ export const SettingsGeneralV2: Component<{ void update.catch(() => setPinchZoom(!checked)) } - const InterfaceSection = () => ( - { - settings.general.setNewLayoutDesigns(checked) - if (checked) return - void import("@/components/dialog-settings").then((module) => { - void dialog.show(() => ) - }) - }} - /> + const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ + { value: "system", label: language.t("theme.scheme.system") }, + { value: "light", label: language.t("theme.scheme.light") }, + { value: "dark", label: language.t("theme.scheme.dark") }, + ]) + + const languageOptions = createMemo(() => + language.locales.map((locale) => ({ + value: locale, + label: language.label(locale), + })), ) - const InterfaceNoticeSection = () => ( - settings.general.dismissNewInterfaceNotice()} - /> - ) + const noneSound = { id: "none", label: "sound.option.none" } as const + const soundOptions = [noneSound, ...SOUND_OPTIONS] + const mono = () => monoInput(settings.appearance.font()) + const sans = () => sansInput(settings.appearance.uiFont()) + const terminal = () => terminalInput(settings.appearance.terminalFont()) + + const soundSelectProps = ( + enabled: () => boolean, + current: () => string, + setEnabled: (value: boolean) => void, + set: (id: string) => void, + ) => ({ + options: soundOptions, + current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound, + value: (o: (typeof soundOptions)[number]) => o.id, + label: (o: (typeof soundOptions)[number]) => language.t(o.label), + onHighlight: (option: (typeof soundOptions)[number] | undefined) => { + if (!option) return + playDemoSound(option.id === "none" ? undefined : option.id) + }, + onSelect: (option: (typeof soundOptions)[number] | null) => { + if (!option) return + if (option.id === "none") { + setEnabled(false) + stopDemoSound() + return + } + setEnabled(true) + set(option.id) + playDemoSound(option.id) + }, + }) const GeneralSection = () => (
- + + o.value === language.locale())} + value={(o) => o.value} + label={(o) => o.label} + onSelect={(option) => option && language.setLocale(option.value)} + /> + - + +
+ +
+
- + + o.value === currentShell()) ?? autoOption} + placement="bottom-end" + gutter={6} + value={(o) => o.id} + label={(o) => o.label} + onSelect={(option) => { + if (!option) return + if (option.value === currentShell()) return + serverSync().updateConfig({ shell: option.value }) + }} + /> + - - -
- settings.general.setMobileTitlebarPosition(checked ? "bottom" : "top")} - /> -
-
-
+ +
+ settings.general.setShowSessionProgressBar(checked)} + /> +
+
+ + +
+ { + settings.general.setNewLayoutDesigns(checked) + if (checked) return + void import("@/components/dialog-settings").then((module) => { + dialog.show(() => ) + }) + }} + /> +
+
) @@ -442,6 +405,134 @@ export const SettingsGeneralV2: Component<{
) + const AppearanceSection = () => ( +
+

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

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

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

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

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

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

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

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

- -

- - - - {(item) => { - const key = { providerID: item.provider.id, modelID: item.id } - return ( - -
- { - models.setVisibility(key, checked) - }} - hideLabel - > - {item.name} - -
-
- ) - }} -
-
-
+ {(group) => ( +
+
+ +

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

- ) - }} + + + {(item) => { + const key = { providerID: item.provider.id, modelID: item.id } + return ( + +
+ { + models.setVisibility(key, checked) + }} + hideLabel + > + {item.name} + +
+
+ ) + }} +
+
+
+ )} diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx index acd73ddbe5..cd24bbd455 100644 --- a/packages/app/src/components/settings-v2/providers.tsx +++ b/packages/app/src/components/settings-v2/providers.tsx @@ -4,11 +4,12 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { showToast } from "@/utils/toast" import { popularProviders, useProviders } from "@/hooks/use-providers" -import { createMemo, type Accessor, type Component, For, Show } from "solid-js" +import { createMemo, type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" -import { useServerProtocol, useServerSDK } from "@/context/server-sdk" +import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" -import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider" +import { DialogConnectProvider } from "../dialog-connect-provider" +import { DialogSelectProvider } from "../dialog-select-provider" import { DialogCustomProvider } from "../dialog-custom-provider" import { SettingsListV2 } from "./parts/list" import "./settings-v2.css" @@ -29,22 +30,12 @@ const PROVIDER_NOTES = [ const PROVIDER_ICON_SIZE = 16 -export const SettingsProvidersV2: Component<{ - directory: Accessor - onBack?: () => void -}> = (props) => { +export const SettingsProvidersV2: Component = () => { const dialog = useDialog() const language = useLanguage() const serverSdk = useServerSDK() - const protocol = useServerProtocol() const serverSync = useServerSync() - const providers = useProviders(props.directory) - const providerConnect = useProviderConnectController({ onBack: props.onBack }) - - const connect = (provider?: string) => { - providerConnect.select(provider) - void dialog.show(() => ) - } + const providers = useProviders() const connected = createMemo(() => { return providers @@ -81,8 +72,7 @@ export const SettingsProvidersV2: Component<{ return language.t("settings.providers.tag.other") } - const canDisconnect = (item: ProviderItem) => - source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id)) + const canDisconnect = (item: ProviderItem) => source(item) !== "env" const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key @@ -95,7 +85,6 @@ export const SettingsProvidersV2: Component<{ } const disableProvider = async (providerID: string, name: string) => { - if (protocol() !== "v1") return const before = serverSync().data.config.disabled_providers ?? [] const next = before.includes(providerID) ? before : [...before, providerID] serverSync().set("config", "disabled_providers", next) @@ -217,47 +206,56 @@ export const SettingsProvidersV2: Component<{
- connect(item.id)}> + { + dialog.show(() => ) + }} + > {language.t("common.connect")}
)} - -
-
- -
-
- {language.t("provider.custom.title")} - {language.t("settings.providers.tag.custom")} -
-

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

+
+
+ +
+
+ {language.t("provider.custom.title")} + {language.t("settings.providers.tag.custom")}
+

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

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

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

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