mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-02 16:26:14 -04:00
Compare commits
364 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78880f3288 | |||
| 864f16c703 | |||
| 5e12dbdbfb | |||
| 8efd8783d0 | |||
| 5436b55a7e | |||
| 3a09383d33 | |||
| beefda1f8f | |||
| 2f85f4744d | |||
| ddbc8d3b75 | |||
| cd3885910d | |||
| 4f88cab20d | |||
| 208cd56d03 | |||
| c097d51135 | |||
| 248db536e2 | |||
| e40ce1d556 | |||
| aca5adf39e | |||
| 1a9bd8c1fd | |||
| 20fd323591 | |||
| 7f40aeab21 | |||
| bcff162c60 | |||
| 5ea405bf81 | |||
| 7c0e1b431c | |||
| f4103f5c3a | |||
| e6c45134ab | |||
| f55d8fa3ff | |||
| 78234fa305 | |||
| 98dcea02a8 | |||
| 0149d460e3 | |||
| 5471d2a769 | |||
| b44af11e03 | |||
| 717b740d24 | |||
| b2c688f2a8 | |||
| 1b107301fb | |||
| aee262f019 | |||
| 6ec6db4ad6 | |||
| 40ddede2b1 | |||
| 71402bcbc3 | |||
| d13779b1d5 | |||
| bdee94ca40 | |||
| 9c0d813b8e | |||
| 1bcb9d7cb9 | |||
| 4c0af7cf2d | |||
| d5aa5ff8fb | |||
| 5d4f160d64 | |||
| fed8f01016 | |||
| 259e153d10 | |||
| cfd75d62fe | |||
| dc569b5a5f | |||
| 546527b7d6 | |||
| cdd67cf30f | |||
| c45d1db9a0 | |||
| ba2ba770d5 | |||
| 5ca19947d9 | |||
| 3b4aaafd41 | |||
| 56a37c3640 | |||
| f9dac262ff | |||
| 3730125f8f | |||
| 9bb5370205 | |||
| 25c6abc31a | |||
| f4afb2c0a5 | |||
| bba74985ab | |||
| e9ee2129ea | |||
| 68260ea6c9 | |||
| 49ea8a9455 | |||
| 142c5c11d9 | |||
| 4b83f5d8f0 | |||
| 121decfd15 | |||
| 24b0132bc5 | |||
| 858f35f1b3 | |||
| 20750a3295 | |||
| 1ddb75dd3e | |||
| 315ba3720e | |||
| d1446a9e72 | |||
| 928510608d | |||
| 2b0b573474 | |||
| 62e44a80e7 | |||
| ea5dd9d940 | |||
| 9848587052 | |||
| 547496656c | |||
| 733bb06134 | |||
| 31b58b4704 | |||
| 42bb793574 | |||
| 17f312d537 | |||
| 116ac93ddb | |||
| 2684293291 | |||
| e8e83afbce | |||
| 2e909334c1 | |||
| 69f3b633b8 | |||
| db7c28b3ee | |||
| e45f55d922 | |||
| 04c1730c90 | |||
| 1ef0fd5d01 | |||
| 36501a81c3 | |||
| 6281e35a18 | |||
| bd149f0daa | |||
| 6eec98371a | |||
| 0016dfd9b1 | |||
| fe655b7630 | |||
| 05f335ce62 | |||
| ad3651d8f1 | |||
| 633fc6fc03 | |||
| cacb7d045f | |||
| 2312a15e57 | |||
| d465cd476a | |||
| a4551a94b4 | |||
| abcfeb380b | |||
| 8cc79f7432 | |||
| 67e8b8582e | |||
| 8aff7b8c7b | |||
| 6c12c32fb1 | |||
| fbfd725d73 | |||
| 246d40db73 | |||
| 947e0017f5 | |||
| 800d41ddab | |||
| 5647ed8ba3 | |||
| af31e97493 | |||
| 5303ab09f8 | |||
| 51eb87b256 | |||
| 556337d6dd | |||
| c416ede20d | |||
| 784eea3911 | |||
| a4b9351c0f | |||
| 57895586c2 | |||
| 6298db7a77 | |||
| 5202bf1bf2 | |||
| 516cfe4e09 | |||
| 60aba622ab | |||
| e8610d821c | |||
| 53076999c5 | |||
| f8c9bfd454 | |||
| fed4f4d86b | |||
| 1490752059 | |||
| 4898263dec | |||
| c556bddda3 | |||
| cfddb2407c | |||
| cf80b5c470 | |||
| c17b9557f1 | |||
| 10f6bb7878 | |||
| b8cfd69acf | |||
| d2305d4a76 | |||
| c6cc13e183 | |||
| dcf7b4e792 | |||
| 5152150bfe | |||
| 2ba18b84a5 | |||
| e04c5e72f7 | |||
| a131811cdc | |||
| 976e5d421f | |||
| 8e2d422ffe | |||
| a3825286cf | |||
| e0c0411003 | |||
| e439456b13 | |||
| e3edbba3ca | |||
| 3cdd431794 | |||
| ef4500d6af | |||
| f416f52ae3 | |||
| e0ff4fd1ac | |||
| 428ae6d21a | |||
| d568fed095 | |||
| 58a8d9fcb0 | |||
| d24848359d | |||
| a21e74773f | |||
| fc95a84b42 | |||
| b0063709e6 | |||
| 77522697b0 | |||
| bbc88eb8e3 | |||
| a379c7956b | |||
| 52eae83ae4 | |||
| 3cc626b5da | |||
| f0849a697c | |||
| 3f3f120825 | |||
| ea17d9bed8 | |||
| 726b2d7e6b | |||
| bffc5938fa | |||
| d111812389 | |||
| 597f47b486 | |||
| 09757c605a | |||
| 0c4f508c50 | |||
| 40db33c415 | |||
| 9b01f15f1d | |||
| 81851ca6b9 | |||
| 3c5632e110 | |||
| 6ea3e6698a | |||
| ed75ce9ecc | |||
| af14fefc96 | |||
| 4a710e4679 | |||
| d2c866bf70 | |||
| 237595e242 | |||
| d29f5eba92 | |||
| fbf889db83 | |||
| ef2357915e | |||
| 975b1132f1 | |||
| cd97de7391 | |||
| 23fd5907be | |||
| 49d3f86802 | |||
| 909a1a6d78 | |||
| dc468bdcfd | |||
| f48f24ec4e | |||
| 34b3d59a23 | |||
| a0a500316e | |||
| 1787fa4261 | |||
| 130957288e | |||
| c5a4a8288c | |||
| 39d7394ede | |||
| 494123a875 | |||
| fe840d42b8 | |||
| f50e4accf3 | |||
| 9bceb8eb7d | |||
| adebb87191 | |||
| 4d5efba5c9 | |||
| c6ee511485 | |||
| f9f2280452 | |||
| d5980b47e9 | |||
| 41d1279b6f | |||
| 0d32d1f293 | |||
| cf31029350 | |||
| 79b55d4db8 | |||
| 36264ccf90 | |||
| 7b750a8f20 | |||
| e50261e524 | |||
| 639c94a375 | |||
| b13a2d712a | |||
| 1b91b5df34 | |||
| 595cc91ddc | |||
| 57ce1b9ca8 | |||
| 4ecc3ac653 | |||
| c7efbe6fc0 | |||
| cd292a4ecb | |||
| 2bb4311042 | |||
| cdc6d01c5a | |||
| 35b3fc85d0 | |||
| 9dadc2455f | |||
| 06dae383f7 | |||
| 7d204b5b57 | |||
| 49593c1ec4 | |||
| ff837fe949 | |||
| 4c6750d464 | |||
| 69f1ec22e3 | |||
| 823d327401 | |||
| 82d9cab48d | |||
| fb43c15f88 | |||
| ca006a2d20 | |||
| 8396395f17 | |||
| 1b8bab3e35 | |||
| 02687b6324 | |||
| c780d7cee7 | |||
| 7a9337da8a | |||
| f96e6aa6ed | |||
| c6f719e153 | |||
| 1a111be494 | |||
| a0aee82be9 | |||
| c0dc6e50a7 | |||
| 418a9e4e66 | |||
| bd8ce5e6a9 | |||
| a97c6de9db | |||
| 24ea4dd008 | |||
| ffcb7542e1 | |||
| d4d841bafd | |||
| 6f0e934573 | |||
| 233d065dd5 | |||
| f12ac6f234 | |||
| d3bbfff826 | |||
| 468f425e76 | |||
| d59619fffd | |||
| 5606d2bab9 | |||
| e84d94d998 | |||
| 4f1ae1604e | |||
| d99f86b28d | |||
| 22cc758b1a | |||
| 0b7ec51d0a | |||
| 4f1a9d7aef | |||
| 503309d244 | |||
| 1c76587ce2 | |||
| 24c70ec974 | |||
| 2d993cd0d5 | |||
| babe5070e2 | |||
| 009f3799cd | |||
| 95237a90a7 | |||
| e6cdc543f3 | |||
| f092bafe88 | |||
| 3f1fffeb6d | |||
| c6083a474c | |||
| 10ec856ff2 | |||
| 0f6c9b3874 | |||
| 355a0bcf5b | |||
| 2892e97c57 | |||
| 62c746f2e8 | |||
| ec50db334b | |||
| 8716c4309a | |||
| 10b6672be1 | |||
| 85a79292e6 | |||
| 5c9e4ff21b | |||
| 417ad240c7 | |||
| 1e63e76192 | |||
| 213ff3f2d7 | |||
| 3ab3d04ec7 | |||
| 8fd5753f76 | |||
| 273efdeee7 | |||
| 3b811bd019 | |||
| e772664389 | |||
| 1c2c2d6b52 | |||
| 88f5b9a90e | |||
| 25cb2be619 | |||
| bd84c32860 | |||
| 94652cfe4a | |||
| 3a2ff11be8 | |||
| a98d5732c0 | |||
| 5d0f86606a | |||
| 98d66e9a8b | |||
| 0dbfb6bc0b | |||
| 9258e8ca86 | |||
| 9fdfa23f01 | |||
| dfb616f067 | |||
| 1338d7b47a | |||
| a774c62eac | |||
| a9a4b2f00f | |||
| e4ccb505bf | |||
| 85e278b728 | |||
| 87c33b3d85 | |||
| 3f81402663 | |||
| d37ddc501c | |||
| 3e523d506c | |||
| e4d4b07e7d | |||
| 51461429f4 | |||
| 3ab19bfd7d | |||
| 0cf3ee4406 | |||
| 4810df0a71 | |||
| 010b456dfd | |||
| c81cd3202c | |||
| 7ad68f8150 | |||
| 8cc2276dba | |||
| f2cf607376 | |||
| 7efade2d53 | |||
| 231f4944c7 | |||
| f55a931f59 | |||
| 42f339c681 | |||
| 409ba229ab | |||
| 252223a4a7 | |||
| 11fd6f8255 | |||
| 7143bf8ff0 | |||
| 414c037b21 | |||
| 632f94fa68 | |||
| a6e3afe048 | |||
| 3f174531b9 | |||
| fcca731a96 | |||
| c7dee9c609 | |||
| 45e4606fa4 | |||
| 9ae4a5139f | |||
| d73348719e | |||
| 2630f457b9 | |||
| dbbe67f066 | |||
| fff0ec294c | |||
| cf2d1dd3e9 | |||
| 27ca0f8825 | |||
| 73dbd8a312 | |||
| 443f103ee3 | |||
| 6c36b585c3 | |||
| c2e6b18076 | |||
| 8d97c8d412 | |||
| f8b357b267 | |||
| 621796d8ce | |||
| ba2455ecc7 | |||
| 44308dfd7e | |||
| b000256f57 | |||
| 30b2544fe1 |
@@ -2,4 +2,4 @@ blank_issues_enabled: false
|
|||||||
contact_links:
|
contact_links:
|
||||||
- name: 💬 Discord Community
|
- name: 💬 Discord Community
|
||||||
url: https://discord.gg/opencode
|
url: https://discord.gg/opencode
|
||||||
about: For quick questions or real-time discussion. Note that issues are searchable and help others with the same question.
|
about: For support, troubleshooting, how-to questions, and real-time discussion.
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
name: Question
|
|
||||||
description: Ask a question
|
|
||||||
body:
|
|
||||||
- type: textarea
|
|
||||||
id: question
|
|
||||||
attributes:
|
|
||||||
label: Question
|
|
||||||
description: What's your question?
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
@@ -325,6 +325,7 @@ jobs:
|
|||||||
run: bun run build
|
run: bun run build
|
||||||
working-directory: packages/desktop
|
working-directory: packages/desktop
|
||||||
env:
|
env:
|
||||||
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ on:
|
|||||||
- "bun.lock"
|
- "bun.lock"
|
||||||
- "packages/storybook/**"
|
- "packages/storybook/**"
|
||||||
- "packages/ui/**"
|
- "packages/ui/**"
|
||||||
|
- "packages/session-ui/**"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [dev]
|
branches: [dev]
|
||||||
paths:
|
paths:
|
||||||
@@ -17,6 +18,7 @@ on:
|
|||||||
- "bun.lock"
|
- "bun.lock"
|
||||||
- "packages/storybook/**"
|
- "packages/storybook/**"
|
||||||
- "packages/ui/**"
|
- "packages/ui/**"
|
||||||
|
- "packages/session-ui/**"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
|
|||||||
@@ -65,10 +65,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task
|
run: GITHUB_ACTIONS=false bun turbo test
|
||||||
env:
|
env:
|
||||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
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
|
- name: Run HttpApi exerciser gates
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
working-directory: packages/opencode
|
working-directory: packages/opencode
|
||||||
@@ -99,7 +104,8 @@ jobs:
|
|||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
# Playwright 1.59 hangs while extracting Chromium with Node 24.16.
|
||||||
|
node-version: "24.15"
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: ./.github/actions/setup-bun
|
uses: ./.github/actions/setup-bun
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
sst-env.d.ts
|
sst-env.d.ts
|
||||||
packages/desktop/src/bindings.ts
|
packages/desktop/src/bindings.ts
|
||||||
|
packages/client/src/generated/
|
||||||
|
packages/client/src/generated-effect/
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
|
|||||||
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
|
- 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
|
- 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 `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.
|
Reduce total variable count by inlining when a value is only used once.
|
||||||
|
|
||||||
@@ -137,7 +138,7 @@ const table = sqliteTable("session", {
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- Avoid mocks as much as possible
|
- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option.
|
||||||
- Test actual implementation, do not duplicate logic into tests
|
- 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`.
|
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
||||||
|
|
||||||
@@ -152,7 +153,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 `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.
|
- 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.
|
- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
||||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash activity recovery requires a separate explicit design before it may retry provider work.
|
- Keep 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 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 delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once.
|
||||||
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
- Keep 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.
|
- 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.
|
||||||
|
|||||||
+103
-16
@@ -24,7 +24,7 @@ A durable chronological instruction that tells the model the newly effective sta
|
|||||||
_Avoid_: System update, system notification, raw text diff
|
_Avoid_: System update, system notification, raw text diff
|
||||||
|
|
||||||
**Context Epoch**:
|
**Context Epoch**:
|
||||||
The span during which one effective agent's initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition.
|
The span during which one initially rendered **System Context** remains the immutable provider-cache baseline, ending at completed compaction, Session movement, or an incompatible context transition that requires a fresh baseline.
|
||||||
|
|
||||||
**Baseline System Context**:
|
**Baseline System Context**:
|
||||||
The full **System Context** rendered at the start of a **Context Epoch**.
|
The full **System Context** rendered at the start of a **Context Epoch**.
|
||||||
@@ -39,18 +39,41 @@ An expected temporary inability to observe a **Context Source** value; the runti
|
|||||||
**Safe Provider-Turn Boundary**:
|
**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.
|
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**:
|
**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.
|
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.
|
||||||
|
|
||||||
**Managed Tool Output File**:
|
**Managed Tool Output File**:
|
||||||
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
|
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
|
||||||
|
|
||||||
**Model Request Options**:
|
**PTY Environment**:
|
||||||
Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request.
|
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
|
||||||
_Avoid_: Request body, wire options
|
|
||||||
|
|
||||||
**Generation Controls**:
|
**OpenCode Client**:
|
||||||
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
|
The generated Effect API shared by networked and in-process consumers, executed through an `HttpClient` against the same `HttpApi` 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
|
## Relationships
|
||||||
|
|
||||||
@@ -64,6 +87,11 @@ Provider-neutral sampling and output controls, partitioned from provider semanti
|
|||||||
- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**.
|
- 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.
|
- 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**.
|
- 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.
|
- 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.
|
- 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.
|
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
|
||||||
@@ -72,33 +100,75 @@ Provider-neutral sampling and output controls, partitioned from provider semanti
|
|||||||
- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed.
|
- 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.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.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
|
||||||
- `SystemContext.replace(...)` represents an explicit baseline-replacing transition such as compaction or model/provider switch; it either produces a fresh generation or reports that replacement is blocked by unavailable admitted context.
|
- `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable.
|
||||||
- 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.
|
- **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**.
|
- 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**.
|
- 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.
|
- 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.
|
- 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.
|
- 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**.
|
- 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.
|
- 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.
|
- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
|
||||||
- Switching the selected agent requests **Context Epoch** replacement. A switch admitted after the current **Safe Provider-Turn Boundary** applies to the next provider turn while leaving the already-prepared baseline durable. Epoch creation is fenced against the authoritative effective agent, and retries re-observe the current agent.
|
- 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.
|
||||||
- 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.
|
- Selected-agent available-skill guidance remains a **Context Source**. An agent switch that changes that guidance produces a **Mid-Conversation System Message** while preserving the current baseline.
|
||||||
- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy.
|
- 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.
|
- 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.
|
- 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.
|
- **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.
|
- 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** 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** 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.
|
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
|
||||||
- Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache.
|
- 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 always starts a new **Context Epoch** while preserving chronological conversation history.
|
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
|
||||||
- **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.
|
- 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`.
|
||||||
- **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.
|
- 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 `SessionMessageNotFoundError` 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.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.
|
- 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.
|
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
|
||||||
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
|
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
|
||||||
@@ -114,6 +184,23 @@ Provider-neutral sampling and output controls, partitioned from provider semanti
|
|||||||
- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
|
- **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.
|
- 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
|
## Example dialogue
|
||||||
|
|
||||||
> **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?"
|
> **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?"
|
||||||
|
|||||||
@@ -29,12 +29,14 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
|
"@opencode-ai/session-ui": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
|
"@pierre/trees": "1.0.0-beta.4",
|
||||||
"@sentry/solid": "catalog:",
|
"@sentry/solid": "catalog:",
|
||||||
"@shikijs/transformers": "3.9.2",
|
"@shikijs/transformers": "3.9.2",
|
||||||
"@solid-primitives/active-element": "2.1.3",
|
"@solid-primitives/active-element": "2.1.3",
|
||||||
@@ -52,6 +54,7 @@
|
|||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
"@tanstack/solid-query": "5.91.4",
|
"@tanstack/solid-query": "5.91.4",
|
||||||
|
"@tanstack/solid-virtual": "catalog:",
|
||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -65,7 +68,6 @@
|
|||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"solid-list": "catalog:",
|
"solid-list": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
"virtua": "catalog:",
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@happy-dom/global-registrator": "20.0.11",
|
"@happy-dom/global-registrator": "20.0.11",
|
||||||
@@ -85,7 +87,7 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@opencode-ai/cli",
|
"name": "@opencode-ai/cli",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"bin": {
|
"bin": {
|
||||||
"lildax": "./bin/lildax.cjs",
|
"lildax": "./bin/lildax.cjs",
|
||||||
},
|
},
|
||||||
@@ -108,9 +110,32 @@
|
|||||||
"@typescript/native-preview": "catalog:",
|
"@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/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -146,7 +171,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -173,7 +198,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "3.0.82",
|
"@ai-sdk/anthropic": "3.0.82",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
@@ -195,7 +220,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -219,7 +244,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/support": {
|
"packages/console/support": {
|
||||||
"name": "@opencode-ai/console-support",
|
"name": "@opencode-ai/console-support",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@opencode-ai/console-core": "workspace:*",
|
"@opencode-ai/console-core": "workspace:*",
|
||||||
@@ -239,7 +264,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -275,6 +300,8 @@
|
|||||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||||
"@opencode-ai/llm": "workspace:*",
|
"@opencode-ai/llm": "workspace:*",
|
||||||
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "2.9.0",
|
"@openrouter/ai-sdk-provider": "2.9.0",
|
||||||
"@opentelemetry/api": "1.9.0",
|
"@opentelemetry/api": "1.9.0",
|
||||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||||
@@ -330,7 +357,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zip.js/zip.js": "2.7.62",
|
"@zip.js/zip.js": "2.7.62",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -384,7 +411,7 @@
|
|||||||
},
|
},
|
||||||
"packages/effect-drizzle-sqlite": {
|
"packages/effect-drizzle-sqlite": {
|
||||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -398,7 +425,7 @@
|
|||||||
},
|
},
|
||||||
"packages/effect-sqlite-node": {
|
"packages/effect-sqlite-node": {
|
||||||
"name": "@opencode-ai/effect-sqlite-node",
|
"name": "@opencode-ai/effect-sqlite-node",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -410,10 +437,11 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/standard-validator": "catalog:",
|
"@hono/standard-validator": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
|
"@opencode-ai/session-ui": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
@@ -441,7 +469,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -457,10 +485,10 @@
|
|||||||
},
|
},
|
||||||
"packages/http-recorder": {
|
"packages/http-recorder": {
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node": "4.0.0-beta.74",
|
"@effect/platform-node": "4.0.0-beta.83",
|
||||||
"@effect/platform-node-shared": "4.0.0-beta.74",
|
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
@@ -471,13 +499,26 @@
|
|||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"effect": "4.0.0-beta.74",
|
"effect": "4.0.0-beta.83",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages/httpapi-codegen": {
|
||||||
|
"name": "@opencode-ai/httpapi-codegen",
|
||||||
|
"dependencies": {
|
||||||
|
"effect": "catalog:",
|
||||||
|
"prettier": "3.6.2",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/bun": "catalog:",
|
||||||
|
"@types/bun": "catalog:",
|
||||||
|
"@typescript/native-preview": "catalog:",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/llm": {
|
"packages/llm": {
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@smithy/eventstream-codec": "4.2.14",
|
"@smithy/eventstream-codec": "4.2.14",
|
||||||
"@smithy/util-utf8": "4.2.2",
|
"@smithy/util-utf8": "4.2.2",
|
||||||
"aws4fetch": "1.0.20",
|
"aws4fetch": "1.0.20",
|
||||||
@@ -494,7 +535,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -533,6 +574,8 @@
|
|||||||
"@openauthjs/openauth": "catalog:",
|
"@openauthjs/openauth": "catalog:",
|
||||||
"@opencode-ai/llm": "workspace:*",
|
"@opencode-ai/llm": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
|
"@opencode-ai/protocol": "workspace:*",
|
||||||
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/server": "workspace:*",
|
"@opencode-ai/server": "workspace:*",
|
||||||
@@ -622,8 +665,9 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/provider": "3.0.8",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
@@ -648,6 +692,29 @@
|
|||||||
"@opentui/solid",
|
"@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": {
|
"packages/script": {
|
||||||
"name": "@opencode-ai/script",
|
"name": "@opencode-ai/script",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -658,9 +725,23 @@
|
|||||||
"@types/semver": "^7.5.8",
|
"@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": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -675,9 +756,10 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@opencode-ai/server",
|
"name": "@opencode-ai/server",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
|
"@opencode-ai/protocol": "workspace:*",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -687,9 +769,53 @@
|
|||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"packages/session-ui": {
|
||||||
|
"name": "@opencode-ai/session-ui",
|
||||||
|
"version": "1.17.11",
|
||||||
|
"dependencies": {
|
||||||
|
"@kobalte/core": "catalog:",
|
||||||
|
"@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:",
|
||||||
|
"@solidjs/router": "catalog:",
|
||||||
|
"diff": "catalog:",
|
||||||
|
"dompurify": "3.3.1",
|
||||||
|
"fuzzysort": "catalog:",
|
||||||
|
"katex": "0.16.27",
|
||||||
|
"luxon": "catalog:",
|
||||||
|
"marked": "catalog:",
|
||||||
|
"marked-katex-extension": "5.1.6",
|
||||||
|
"marked-shiki": "catalog:",
|
||||||
|
"morphdom": "2.7.8",
|
||||||
|
"motion": "12.34.5",
|
||||||
|
"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": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -702,7 +828,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/app": {
|
"packages/stats/app": {
|
||||||
"name": "@opencode-ai/stats-app",
|
"name": "@opencode-ai/stats-app",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
"@opencode-ai/stats-core": "workspace:*",
|
"@opencode-ai/stats-core": "workspace:*",
|
||||||
@@ -735,7 +861,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/core": {
|
"packages/stats/core": {
|
||||||
"name": "@opencode-ai/stats-core",
|
"name": "@opencode-ai/stats-core",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-athena": "3.933.0",
|
"@aws-sdk/client-athena": "3.933.0",
|
||||||
"@planetscale/database": "1.19.0",
|
"@planetscale/database": "1.19.0",
|
||||||
@@ -754,7 +880,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/server": {
|
"packages/stats/server": {
|
||||||
"name": "@opencode-ai/stats-server",
|
"name": "@opencode-ai/stats-server",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-firehose": "3.933.0",
|
"@aws-sdk/client-firehose": "3.933.0",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
@@ -773,6 +899,7 @@
|
|||||||
"packages/storybook": {
|
"packages/storybook": {
|
||||||
"name": "@opencode-ai/storybook",
|
"name": "@opencode-ai/storybook",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@opencode-ai/session-ui": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@storybook/addon-a11y": "^10.2.13",
|
"@storybook/addon-a11y": "^10.2.13",
|
||||||
@@ -794,7 +921,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tui": {
|
"packages/tui": {
|
||||||
"name": "@opencode-ai/tui",
|
"name": "@opencode-ai/tui",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
@@ -821,19 +948,16 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
|
"@shikijs/stream": "catalog:",
|
||||||
"@shikijs/transformers": "3.9.2",
|
"@shikijs/transformers": "3.9.2",
|
||||||
"@solid-primitives/bounds": "0.1.3",
|
"@solid-primitives/bounds": "0.1.3",
|
||||||
"@solid-primitives/event-listener": "2.4.5",
|
"@solid-primitives/event-listener": "2.4.5",
|
||||||
"@solid-primitives/media": "2.3.3",
|
"@solid-primitives/media": "2.3.3",
|
||||||
"@solid-primitives/resize-observer": "2.1.3",
|
"@solid-primitives/resize-observer": "2.1.3",
|
||||||
"@solidjs/meta": "catalog:",
|
|
||||||
"@solidjs/router": "catalog:",
|
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
@@ -849,28 +973,32 @@
|
|||||||
"remeda": "catalog:",
|
"remeda": "catalog:",
|
||||||
"remend": "catalog:",
|
"remend": "catalog:",
|
||||||
"shiki": "catalog:",
|
"shiki": "catalog:",
|
||||||
"solid-js": "catalog:",
|
|
||||||
"solid-list": "catalog:",
|
"solid-list": "catalog:",
|
||||||
"strip-ansi": "7.1.2",
|
"strip-ansi": "7.1.2",
|
||||||
"virtua": "catalog:",
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@solidjs/meta": "catalog:",
|
||||||
"@tailwindcss/vite": "catalog:",
|
"@tailwindcss/vite": "catalog:",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/katex": "0.16.7",
|
"@types/katex": "0.16.7",
|
||||||
"@types/luxon": "catalog:",
|
"@types/luxon": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
|
"solid-js": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||||
"vite-plugin-solid": "catalog:",
|
"vite-plugin-solid": "catalog:",
|
||||||
},
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@solidjs/meta": "^0.29.0",
|
||||||
|
"solid-js": "^1.9.0",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -912,15 +1040,18 @@
|
|||||||
"tree-sitter-bash",
|
"tree-sitter-bash",
|
||||||
],
|
],
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||||
"virtua@0.49.1": "patches/virtua@0.49.1.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",
|
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
|
||||||
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
|
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
|
||||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
"@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",
|
"@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",
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
@@ -931,9 +1062,9 @@
|
|||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@cloudflare/workers-types": "4.20251008.0",
|
"@cloudflare/workers-types": "4.20251008.0",
|
||||||
"@effect/opentelemetry": "4.0.0-beta.74",
|
"@effect/opentelemetry": "4.0.0-beta.83",
|
||||||
"@effect/platform-node": "4.0.0-beta.74",
|
"@effect/platform-node": "4.0.0-beta.83",
|
||||||
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
|
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
|
||||||
"@hono/standard-validator": "0.2.0",
|
"@hono/standard-validator": "0.2.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
@@ -944,15 +1075,17 @@
|
|||||||
"@opentui/core": "0.3.4",
|
"@opentui/core": "0.3.4",
|
||||||
"@opentui/keymap": "0.3.4",
|
"@opentui/keymap": "0.3.4",
|
||||||
"@opentui/solid": "0.3.4",
|
"@opentui/solid": "0.3.4",
|
||||||
"@pierre/diffs": "1.1.0-beta.18",
|
"@pierre/diffs": "1.2.10",
|
||||||
"@playwright/test": "1.59.1",
|
"@playwright/test": "1.59.1",
|
||||||
"@sentry/solid": "10.36.0",
|
"@sentry/solid": "10.36.0",
|
||||||
"@sentry/vite-plugin": "4.6.0",
|
"@sentry/vite-plugin": "4.6.0",
|
||||||
|
"@shikijs/stream": "4.2.0",
|
||||||
"@solid-primitives/storage": "4.3.3",
|
"@solid-primitives/storage": "4.3.3",
|
||||||
"@solidjs/meta": "0.29.4",
|
"@solidjs/meta": "0.29.4",
|
||||||
"@solidjs/router": "0.15.4",
|
"@solidjs/router": "0.15.4",
|
||||||
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
|
"@tanstack/solid-virtual": "3.13.28",
|
||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
"@tsconfig/node22": "22.0.2",
|
"@tsconfig/node22": "22.0.2",
|
||||||
"@types/bun": "1.3.13",
|
"@types/bun": "1.3.13",
|
||||||
@@ -967,7 +1100,7 @@
|
|||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-rc.2",
|
"drizzle-kit": "1.0.0-rc.2",
|
||||||
"drizzle-orm": "1.0.0-rc.2",
|
"drizzle-orm": "1.0.0-rc.2",
|
||||||
"effect": "4.0.0-beta.74",
|
"effect": "4.0.0-beta.83",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
@@ -978,14 +1111,13 @@
|
|||||||
"remeda": "2.26.0",
|
"remeda": "2.26.0",
|
||||||
"remend": "1.3.0",
|
"remend": "1.3.0",
|
||||||
"semver": "7.7.4",
|
"semver": "7.7.4",
|
||||||
"shiki": "3.20.0",
|
"shiki": "4.2.0",
|
||||||
"solid-js": "1.9.10",
|
"solid-js": "1.9.10",
|
||||||
"solid-list": "0.3.0",
|
"solid-list": "0.3.0",
|
||||||
"sst": "4.13.1",
|
"sst": "4.13.1",
|
||||||
"tailwindcss": "4.1.11",
|
"tailwindcss": "4.1.11",
|
||||||
"typescript": "5.8.2",
|
"typescript": "5.8.2",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
"virtua": "0.49.1",
|
|
||||||
"vite": "7.1.4",
|
"vite": "7.1.4",
|
||||||
"vite-plugin-solid": "2.11.10",
|
"vite-plugin-solid": "2.11.10",
|
||||||
"zod": "4.1.8",
|
"zod": "4.1.8",
|
||||||
@@ -1323,13 +1455,13 @@
|
|||||||
|
|
||||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
||||||
|
|
||||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.74", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.74" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-flpyqLPyr+THSe6ZCGRZl6hi+FqxbIXNSkslKGiRJAjbPabam9mSp7R3aC8biIMt6xE4Fd0LNfo4p2GplUkm2Q=="],
|
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.83", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.83" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-cPfCfp/ghu0itbX6Dqjdr4N0rbjng5ON4sUpnLHV5JJySG8zZpWmuOZLWIrfrNKT2ctYR1BYmp1aYCgkItaJLw=="],
|
||||||
|
|
||||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.74", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.74", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74", "ioredis": "^5.7.0" } }, "sha512-/W16mKqxvhWINLjufzc0log1sl57exXQfwd+em398/zKCbmU3S7snXTDMN6w0ju2TtgK35qrsoGBXEochij6Sg=="],
|
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.83", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.83", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83", "ioredis": "^5.7.0" } }, "sha512-RmpVGu/+X/Bif3/g1Rzj8oFzTOknoVB3yHCa0b179vytPpKe+Kj9ZwKNcAnKWqHUDkbSPBq1Ca60mvOHr2/+LQ=="],
|
||||||
|
|
||||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.74", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-C6C2hXixNcZXLaFF2u7B/FtOsqpdY7luaPuiGFBJza0P7EnYDkwaT3kB6lv7l/qctmkADc24qOsSCWIKRbC4jg=="],
|
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.83", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-+yr/+PJmKTgmJq1QOINSBPgLu7Cjc4CZcotBXnGjyDEizOmimFgTkN2B8PBJAKIKUWYWfobjXqC+58/VhhPKAw=="],
|
||||||
|
|
||||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.74", "", { "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-RVMRVY7NhSoAp9cAAyy4TT6dt6NNZjOpWeqticoho9HNBukxQSUcu/kjcz4Iq9eoQfXadmepu8kZqtdZULM/fg=="],
|
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.83", "", { "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-6OaxLsWffxkh9pXYUSyj/AxjVb9URY2rG9U6atjxClWy30Jx77R9Pm3Rrc7cQ63kQurePavEw1bQbzQ/SILiQQ=="],
|
||||||
|
|
||||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||||
|
|
||||||
@@ -1767,6 +1899,8 @@
|
|||||||
|
|
||||||
"@opencode-ai/cli": ["@opencode-ai/cli@workspace:packages/cli"],
|
"@opencode-ai/cli": ["@opencode-ai/cli@workspace:packages/cli"],
|
||||||
|
|
||||||
|
"@opencode-ai/client": ["@opencode-ai/client@workspace:packages/client"],
|
||||||
|
|
||||||
"@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"],
|
"@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"],
|
||||||
|
|
||||||
"@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"],
|
"@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"],
|
||||||
@@ -1793,16 +1927,26 @@
|
|||||||
|
|
||||||
"@opencode-ai/http-recorder": ["@opencode-ai/http-recorder@workspace:packages/http-recorder"],
|
"@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/llm": ["@opencode-ai/llm@workspace:packages/llm"],
|
||||||
|
|
||||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
|
"@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/script": ["@opencode-ai/script@workspace:packages/script"],
|
||||||
|
|
||||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"],
|
"@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/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/slack": ["@opencode-ai/slack@workspace:packages/slack"],
|
||||||
|
|
||||||
"@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"],
|
"@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"],
|
||||||
@@ -2121,9 +2265,13 @@
|
|||||||
|
|
||||||
"@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="],
|
"@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="],
|
||||||
|
|
||||||
"@pierre/diffs": ["@pierre/diffs@1.1.0-beta.18", "", { "dependencies": { "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-7ZF3YD9fxdbYsPnltz5cUqHacN7ztp8RX/fJLxwv8wIEORpP4+7dHz1h/qx3o4EW2xUrIhmbM8ImywLasB787Q=="],
|
"@pierre/diffs": ["@pierre/diffs@1.2.10", "", { "dependencies": { "@pierre/theme": "1.0.3", "@pierre/theming": "0.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-rPeAmDWarxFVTQpaf4y6wTxjZxU44xKJKoJti2zU21P06DVd9nRHZX+xSIObLB307Qjpaesyb1x/j0z94t7vLw=="],
|
||||||
|
|
||||||
"@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="],
|
"@pierre/theme": ["@pierre/theme@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="],
|
||||||
|
|
||||||
|
"@pierre/theming": ["@pierre/theming@0.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-1thlEtJbqdyLzc1ZS2KQa1q7FzDGHT4dTEdKHoyQjOMeWWOmbVG5/ndEfOKfAb5Fzkz8cNJrOjFLiZoDH/A03A=="],
|
||||||
|
|
||||||
|
"@pierre/trees": ["@pierre/trees@1.0.0-beta.4", "", { "dependencies": { "preact": "11.0.0-beta.0", "preact-render-to-string": "6.6.5" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag=="],
|
||||||
|
|
||||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||||
|
|
||||||
@@ -2327,13 +2475,17 @@
|
|||||||
|
|
||||||
"@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
|
"@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
|
||||||
|
|
||||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="],
|
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="],
|
||||||
|
|
||||||
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="],
|
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g=="],
|
||||||
|
|
||||||
"@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="],
|
"@shikijs/langs": ["@shikijs/langs@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ=="],
|
||||||
|
|
||||||
"@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="],
|
"@shikijs/primitive": ["@shikijs/primitive@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA=="],
|
||||||
|
|
||||||
|
"@shikijs/stream": ["@shikijs/stream@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0" }, "peerDependencies": { "react": "^19.0.0", "solid-js": "^1.9.0", "vue": "^3.2.0" }, "optionalPeers": ["react", "solid-js", "vue"] }, "sha512-OaMUUStdIZ+l1GJad9uVACR3Xvgwo4y+RmEuDMU62cgFMMg1IBCaIFmvzAR2HiCpGtwoc/qPfpNnP+ivgrPXZg=="],
|
||||||
|
|
||||||
|
"@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="],
|
||||||
|
|
||||||
"@shikijs/transformers": ["@shikijs/transformers@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/types": "3.9.2" } }, "sha512-MW5hT4TyUp6bNAgTExRYLk1NNasVQMTCw1kgbxHcEC0O5cbepPWaB+1k+JzW9r3SP2/R8kiens8/3E6hGKfgsA=="],
|
"@shikijs/transformers": ["@shikijs/transformers@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/types": "3.9.2" } }, "sha512-MW5hT4TyUp6bNAgTExRYLk1NNasVQMTCw1kgbxHcEC0O5cbepPWaB+1k+JzW9r3SP2/R8kiens8/3E6hGKfgsA=="],
|
||||||
|
|
||||||
@@ -2577,6 +2729,10 @@
|
|||||||
|
|
||||||
"@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
|
"@tanstack/solid-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.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.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=="],
|
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
|
||||||
|
|
||||||
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
|
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
|
||||||
@@ -3349,7 +3505,7 @@
|
|||||||
|
|
||||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||||
|
|
||||||
"effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
|
"effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="],
|
||||||
|
|
||||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||||
|
|
||||||
@@ -4561,6 +4717,10 @@
|
|||||||
|
|
||||||
"powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
|
"powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
|
||||||
|
|
||||||
|
"preact": ["preact@11.0.0-beta.0", "", {}, "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg=="],
|
||||||
|
|
||||||
|
"preact-render-to-string": ["preact-render-to-string@6.6.5", "", { "peerDependencies": { "preact": ">=10 || >= 11.0.0-0" } }, "sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA=="],
|
||||||
|
|
||||||
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
|
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
|
||||||
|
|
||||||
"pretty": ["pretty@2.0.0", "", { "dependencies": { "condense-newlines": "^0.2.1", "extend-shallow": "^2.0.1", "js-beautify": "^1.6.12" } }, "sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w=="],
|
"pretty": ["pretty@2.0.0", "", { "dependencies": { "condense-newlines": "^0.2.1", "extend-shallow": "^2.0.1", "js-beautify": "^1.6.12" } }, "sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w=="],
|
||||||
@@ -4843,7 +5003,7 @@
|
|||||||
|
|
||||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||||
|
|
||||||
"shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="],
|
"shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="],
|
||||||
|
|
||||||
"shikiji": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="],
|
"shikiji": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="],
|
||||||
|
|
||||||
@@ -5253,8 +5413,6 @@
|
|||||||
|
|
||||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||||
|
|
||||||
"virtua": ["virtua@0.49.1", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-6f79msqg3jzNFdqJiS0FSzhRN1EHlDhR7EvW7emp6z5qQ22VdsReiDHflkpMEMhoAyUuYr69nwT0aagiM7NrUg=="],
|
|
||||||
|
|
||||||
"vite": ["vite@7.1.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw=="],
|
"vite": ["vite@7.1.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw=="],
|
||||||
|
|
||||||
"vite-plugin-dynamic-import": ["vite-plugin-dynamic-import@1.6.0", "", { "dependencies": { "acorn": "^8.12.1", "es-module-lexer": "^1.5.4", "fast-glob": "^3.3.2", "magic-string": "^0.30.11" } }, "sha512-TM0sz70wfzTIo9YCxVFwS8OA9lNREsh+0vMHGSkWDTZ7bgd1Yjs5RV8EgB634l/91IsXJReg0xtmuQqP0mf+rg=="],
|
"vite-plugin-dynamic-import": ["vite-plugin-dynamic-import@1.6.0", "", { "dependencies": { "acorn": "^8.12.1", "es-module-lexer": "^1.5.4", "fast-glob": "^3.3.2", "magic-string": "^0.30.11" } }, "sha512-TM0sz70wfzTIo9YCxVFwS8OA9lNREsh+0vMHGSkWDTZ7bgd1Yjs5RV8EgB634l/91IsXJReg0xtmuQqP0mf+rg=="],
|
||||||
@@ -5503,6 +5661,8 @@
|
|||||||
|
|
||||||
"@astrojs/markdown-remark/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
"@astrojs/markdown-remark/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||||
|
|
||||||
|
"@astrojs/markdown-remark/shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="],
|
||||||
|
|
||||||
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="],
|
"@astrojs/mdx/@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/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
"@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||||
@@ -5807,6 +5967,8 @@
|
|||||||
|
|
||||||
"@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/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/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/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=="],
|
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
||||||
@@ -5841,13 +6003,17 @@
|
|||||||
|
|
||||||
"@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
"@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
"@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||||
|
|
||||||
"@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
"@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||||
|
|
||||||
"@shikijs/langs/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
"@shikijs/langs/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||||
|
|
||||||
"@shikijs/themes/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
"@shikijs/primitive/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||||
|
|
||||||
|
"@shikijs/stream/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="],
|
||||||
|
|
||||||
|
"@shikijs/themes/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||||
|
|
||||||
"@slack/bolt/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
"@slack/bolt/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||||
|
|
||||||
@@ -5873,6 +6039,10 @@
|
|||||||
|
|
||||||
"@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
|
"@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
|
||||||
|
|
||||||
|
"@standard-community/standard-json/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
|
||||||
|
|
||||||
|
"@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
|
||||||
|
|
||||||
"@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
|
"@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
@@ -5957,6 +6127,8 @@
|
|||||||
|
|
||||||
"astro/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
"astro/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||||
|
|
||||||
|
"astro/shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="],
|
||||||
|
|
||||||
"astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
|
"astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
|
||||||
|
|
||||||
"astro/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
|
"astro/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
|
||||||
@@ -6201,9 +6373,9 @@
|
|||||||
|
|
||||||
"sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
"shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
"shiki/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="],
|
||||||
|
|
||||||
"shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
"shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||||
|
|
||||||
"slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
"slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||||
|
|
||||||
@@ -6345,6 +6517,18 @@
|
|||||||
|
|
||||||
"@astrojs/markdown-remark/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
"@astrojs/markdown-remark/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
|
"@astrojs/markdown-remark/shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
||||||
|
|
||||||
|
"@astrojs/markdown-remark/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="],
|
||||||
|
|
||||||
|
"@astrojs/markdown-remark/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="],
|
||||||
|
|
||||||
|
"@astrojs/markdown-remark/shiki/@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="],
|
||||||
|
|
||||||
|
"@astrojs/markdown-remark/shiki/@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="],
|
||||||
|
|
||||||
|
"@astrojs/markdown-remark/shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||||
|
|
||||||
"@astrojs/mdx/@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.6", "", {}, "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q=="],
|
"@astrojs/mdx/@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.6", "", {}, "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q=="],
|
||||||
|
|
||||||
"@astrojs/mdx/@astrojs/markdown-remark/@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="],
|
"@astrojs/mdx/@astrojs/markdown-remark/@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="],
|
||||||
@@ -6641,6 +6825,8 @@
|
|||||||
|
|
||||||
"@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
|
"@shikijs/stream/@shikijs/core/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||||
|
|
||||||
"@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
"@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
"@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
"@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||||
@@ -6659,6 +6845,10 @@
|
|||||||
|
|
||||||
"@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
|
"@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
|
||||||
|
|
||||||
|
"@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
"@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=="],
|
"@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=="],
|
||||||
@@ -6707,6 +6897,18 @@
|
|||||||
|
|
||||||
"astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
"astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
|
"astro/shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
||||||
|
|
||||||
|
"astro/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="],
|
||||||
|
|
||||||
|
"astro/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="],
|
||||||
|
|
||||||
|
"astro/shiki/@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="],
|
||||||
|
|
||||||
|
"astro/shiki/@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="],
|
||||||
|
|
||||||
|
"astro/shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||||
|
|
||||||
"astro/unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
"astro/unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||||
|
|
||||||
"astro/unstorage/h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="],
|
"astro/unstorage/h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="],
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
exact = true
|
exact = true
|
||||||
# Only install newly resolved package versions published at least 3 days ago.
|
# Only install newly resolved package versions published at least 3 days ago.
|
||||||
minimumReleaseAge = 259200
|
minimumReleaseAge = 259200
|
||||||
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
|
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
|
||||||
|
|
||||||
[test]
|
[test]
|
||||||
root = "./do-not-run-tests-from-root"
|
root = "./do-not-run-tests-from-root"
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
SECRET.UpstashRedisRestToken,
|
SECRET.UpstashRedisRestToken,
|
||||||
AUTH_API_URL,
|
AUTH_API_URL,
|
||||||
STRIPE_WEBHOOK_SECRET,
|
STRIPE_WEBHOOK_SECRET,
|
||||||
|
SECRET.SupportApiKey,
|
||||||
DISCORD_INCIDENT_WEBHOOK_URL,
|
DISCORD_INCIDENT_WEBHOOK_URL,
|
||||||
SECRET.HoneycombWebhookSecret,
|
SECRET.HoneycombWebhookSecret,
|
||||||
STRIPE_SECRET_KEY,
|
STRIPE_SECRET_KEY,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ new sst.cloudflare.x.SolidStart("Teams", {
|
|||||||
domain: shortDomain,
|
domain: shortDomain,
|
||||||
path: "packages/enterprise",
|
path: "packages/enterprise",
|
||||||
buildCommand: "bun run build:cloudflare",
|
buildCommand: "bun run build:cloudflare",
|
||||||
|
link: [SECRET.SupportApiKey],
|
||||||
environment: {
|
environment: {
|
||||||
OPENCODE_STORAGE_ADAPTER: "r2",
|
OPENCODE_STORAGE_ADAPTER: "r2",
|
||||||
OPENCODE_STORAGE_ACCOUNT_ID: sst.cloudflare.DEFAULT_ACCOUNT_ID,
|
OPENCODE_STORAGE_ACCOUNT_ID: sst.cloudflare.DEFAULT_ACCOUNT_ID,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const SECRET = {
|
|||||||
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
|
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
|
||||||
HoneycombApiKey: new sst.Secret("HONEYCOMB_API_KEY"),
|
HoneycombApiKey: new sst.Secret("HONEYCOMB_API_KEY"),
|
||||||
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
|
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
|
||||||
|
SupportApiKey: new sst.Secret("SUPPORT_API_KEY"),
|
||||||
UpstashRedisRestUrl: new sst.Secret("UpstashRedisRestUrl"),
|
UpstashRedisRestUrl: new sst.Secret("UpstashRedisRestUrl"),
|
||||||
UpstashRedisRestToken: new sst.Secret("UpstashRedisRestToken"),
|
UpstashRedisRestToken: new sst.Secret("UpstashRedisRestToken"),
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -42,11 +42,12 @@ const inferenceEventTable = new aws.s3tables.Table(
|
|||||||
{ name: "request", type: "string", required: false },
|
{ name: "request", type: "string", required: false },
|
||||||
{ name: "client", type: "string", required: false },
|
{ name: "client", type: "string", required: false },
|
||||||
{ name: "user_agent", 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: "model_variant", type: "string", required: false },
|
||||||
{ name: "source", type: "string", required: false },
|
{ name: "source", type: "string", required: false },
|
||||||
{ name: "provider", type: "string", required: false },
|
{ name: "provider", type: "string", required: false },
|
||||||
{ name: "provider_model", 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_code", type: "int", required: false },
|
||||||
{ name: "llm_error_message", type: "string", required: false },
|
{ name: "llm_error_message", type: "string", required: false },
|
||||||
{ name: "error_response", type: "string", required: false },
|
{ name: "error_response", type: "string", required: false },
|
||||||
@@ -56,6 +57,7 @@ const inferenceEventTable = new aws.s3tables.Table(
|
|||||||
{ name: "error_cause2", type: "string", required: false },
|
{ name: "error_cause2", type: "string", required: false },
|
||||||
{ name: "api_key", type: "string", required: false },
|
{ name: "api_key", type: "string", required: false },
|
||||||
{ name: "workspace", 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: "is_subscription", type: "boolean", required: false },
|
||||||
{ name: "subscription", type: "string", required: false },
|
{ name: "subscription", type: "string", required: false },
|
||||||
{ name: "response_length", type: "long", required: false },
|
{ name: "response_length", type: "long", required: false },
|
||||||
@@ -84,7 +86,7 @@ const inferenceEventTable = new aws.s3tables.Table(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ deleteBeforeReplace: $app.stage !== "production" },
|
{ deleteBeforeReplace: $app.stage !== "production", ignoreChanges: ["metadata"] },
|
||||||
)
|
)
|
||||||
|
|
||||||
export const inferenceEvent = new sst.Linkable("InferenceEvent", {
|
export const inferenceEvent = new sst.Linkable("InferenceEvent", {
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-u3CBUflGrE1unVRTvvLKaDkvx5ZHXyRLd/SJOUUsRdc=",
|
"x86_64-linux": "sha256-drc/Ev96W6b8b0b5LqdZeeGDQ1SMgsz8r5cMO91ei2o=",
|
||||||
"aarch64-linux": "sha256-hUu70IlOXXQ9apQe3IPOdQGf3ZSVCpvRuAWSKxipk/k=",
|
"aarch64-linux": "sha256-Ti0hNjhUgkVtdb54vea/lpI0ltDwLoPitVyHtx4JGwY=",
|
||||||
"aarch64-darwin": "sha256-N6Mqmo94q0rsskLcXoUGWCMJjQ9wCuuo0Lby3j6+Dps=",
|
"aarch64-darwin": "sha256-br4iQ/kK3tSGp+1FefiCTlwsCRhHHhGbKzSixGWaCto=",
|
||||||
"x86_64-darwin": "sha256-5691VKK2zWqAwe94CCjZuRjXEaswedevFtHxensVLfg="
|
"x86_64-darwin": "sha256-h7yje968Kyh8/mVY19YmDB5g693XDhIf0XnuwukzCWE="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
|||||||
writableTmpDirAsHomeHook
|
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 = ''
|
configurePhase = ''
|
||||||
runHook preConfigure
|
runHook preConfigure
|
||||||
|
|
||||||
|
|||||||
+13
-9
@@ -30,9 +30,9 @@
|
|||||||
"packages/slack"
|
"packages/slack"
|
||||||
],
|
],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@effect/opentelemetry": "4.0.0-beta.74",
|
"@effect/opentelemetry": "4.0.0-beta.83",
|
||||||
"@effect/platform-node": "4.0.0-beta.74",
|
"@effect/platform-node": "4.0.0-beta.83",
|
||||||
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
|
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
|
||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@types/bun": "1.3.13",
|
"@types/bun": "1.3.13",
|
||||||
"@types/cross-spawn": "6.0.6",
|
"@types/cross-spawn": "6.0.6",
|
||||||
@@ -42,6 +42,8 @@
|
|||||||
"@opentui/core": "0.3.4",
|
"@opentui/core": "0.3.4",
|
||||||
"@opentui/keymap": "0.3.4",
|
"@opentui/keymap": "0.3.4",
|
||||||
"@opentui/solid": "0.3.4",
|
"@opentui/solid": "0.3.4",
|
||||||
|
"@tanstack/solid-virtual": "3.13.28",
|
||||||
|
"@shikijs/stream": "4.2.0",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@types/luxon": "3.7.1",
|
"@types/luxon": "3.7.1",
|
||||||
@@ -51,7 +53,7 @@
|
|||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
"@cloudflare/workers-types": "4.20251008.0",
|
"@cloudflare/workers-types": "4.20251008.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@pierre/diffs": "1.1.0-beta.18",
|
"@pierre/diffs": "1.2.10",
|
||||||
"opentui-spinner": "0.0.7",
|
"opentui-spinner": "0.0.7",
|
||||||
"@solid-primitives/storage": "4.3.3",
|
"@solid-primitives/storage": "4.3.3",
|
||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
@@ -59,7 +61,7 @@
|
|||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-rc.2",
|
"drizzle-kit": "1.0.0-rc.2",
|
||||||
"drizzle-orm": "1.0.0-rc.2",
|
"drizzle-orm": "1.0.0-rc.2",
|
||||||
"effect": "4.0.0-beta.74",
|
"effect": "4.0.0-beta.83",
|
||||||
"ai": "6.0.168",
|
"ai": "6.0.168",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
@@ -76,10 +78,9 @@
|
|||||||
"zod": "4.1.8",
|
"zod": "4.1.8",
|
||||||
"remeda": "2.26.0",
|
"remeda": "2.26.0",
|
||||||
"sst": "4.13.1",
|
"sst": "4.13.1",
|
||||||
"shiki": "3.20.0",
|
"shiki": "4.2.0",
|
||||||
"solid-list": "0.3.0",
|
"solid-list": "0.3.0",
|
||||||
"tailwindcss": "4.1.11",
|
"tailwindcss": "4.1.11",
|
||||||
"virtua": "0.49.1",
|
|
||||||
"vite": "7.1.4",
|
"vite": "7.1.4",
|
||||||
"@solidjs/meta": "0.29.4",
|
"@solidjs/meta": "0.29.4",
|
||||||
"@solidjs/router": "0.15.4",
|
"@solidjs/router": "0.15.4",
|
||||||
@@ -145,10 +146,13 @@
|
|||||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.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",
|
"@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",
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||||
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
|
|
||||||
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
|
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
|
||||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch"
|
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||||
|
"@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",
|
||||||
|
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
## Priorities
|
||||||
|
|
||||||
|
- Prioritise, in this order: stability, simplicity, performance.
|
||||||
|
- Before changing session or timeline code, record a production benchmark baseline and compare it after the change.
|
||||||
|
|
||||||
## Debugging
|
## Debugging
|
||||||
|
|
||||||
- NEVER try to restart the app, or the server process, EVER.
|
- NEVER try to restart the app, or the server process, EVER.
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
- Prioritize stability, then simplicity, then measurement overhead.
|
||||||
|
- Use Playwright for scenario control, isolation, and completion checks.
|
||||||
|
- Use Chrome Performance traces for generic browser profiling.
|
||||||
|
- Use Electron `contentTracing` for packaged multi-process profiling.
|
||||||
|
- Keep custom probes only for product-specific measurements.
|
||||||
|
- Do not duplicate measurements across the harness, probes, and traces.
|
||||||
|
- Run benchmarks serially to avoid cross-test contention.
|
||||||
|
- Run benchmarks against production builds.
|
||||||
|
- Keep detailed profiling opt-in when it changes workload behavior.
|
||||||
|
- Preserve raw diagnostic data or use lossless representations.
|
||||||
|
- Do not enforce machine-dependent performance thresholds.
|
||||||
|
- Assert scenario completion and metric collection only.
|
||||||
|
- Keep normal test discovery free of manual benchmarks.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Manual app performance suite
|
||||||
|
|
||||||
|
The app's high-volume performance diagnostics live under `packages/app/e2e/performance` and are excluded from normal local and CI Playwright discovery. The benchmark config builds the app and serves the production bundle before running scenarios serially.
|
||||||
|
|
||||||
|
Run the suite explicitly from `packages/app`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun run test:bench
|
||||||
|
```
|
||||||
|
|
||||||
|
PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:PLAYWRIGHT_WORKERS = "1"
|
||||||
|
bun run test:bench
|
||||||
|
```
|
||||||
|
|
||||||
|
The suite contains:
|
||||||
|
|
||||||
|
- cold and hot session-tab timing
|
||||||
|
- home-session click timing split between content and titlebar-tab paint
|
||||||
|
- single-session tab close timing through stable home restoration
|
||||||
|
- cached session repaint and mutation tracing
|
||||||
|
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
|
||||||
|
|
||||||
|
All benchmarks import the shared `benchmark` fixture. Pages created through Playwright's `page` fixture automatically capture main-frame navigation history and emit a Chrome trace when `OPENCODE_PERFORMANCE_TRACE_DIR` is set. Benchmarks that need isolated browser contexts use `withBenchmarkPage`, which owns the context and the same diagnostics lifecycle.
|
||||||
|
|
||||||
|
New benchmarks should look like normal Playwright tests:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { benchmark, expect } from "../benchmark"
|
||||||
|
|
||||||
|
benchmark("measures one interaction", async ({ page, report }) => {
|
||||||
|
// Only scenario-specific setup and interaction belong here.
|
||||||
|
report({ durationMs: 42 })
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The fixture requires every benchmark to call `report()`, automatically names and closes traces, captures navigation history, attaches that history when a test fails, and emits metrics as a consistent `BENCHMARK` JSON line.
|
||||||
|
|
||||||
|
```text
|
||||||
|
BENCHMARK {"name":"...","context":{"project":"chromium","platform":"darwin"},"metrics":{...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, and optional trace path before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide page-lifetime diagnostics; scenario metrics use narrower explicitly named observation windows.
|
||||||
|
|
||||||
|
This follows the stack's own guidance: [Electron recommends repeated Chrome DevTools and Chrome Tracing measurement](https://www.electronjs.org/docs/latest/tutorial/performance), [Chrome DevTools recommends Performance recordings for runtime work](https://developer.chrome.com/docs/devtools/performance), and [Playwright uses traces for test debugging rather than renderer profiling](https://playwright.dev/docs/trace-viewer).
|
||||||
|
|
||||||
|
These Playwright benchmarks profile the shared app renderer in Chromium. A future packaged Electron benchmark that needs main-process and multi-process attribution should use Electron's official [`contentTracing`](https://www.electronjs.org/docs/latest/api/content-tracing/) API rather than extending this renderer harness with bespoke process instrumentation.
|
||||||
|
|
||||||
|
CPU and high-volume visual profiling are disabled by default. Set `TIMELINE_CPU_PROFILE=1` to enable both, or additionally set `TIMELINE_VISUAL_PROFILE=0` for CPU-only profiling.
|
||||||
|
|
||||||
|
The streaming scenario's 30x CPU throttle is a deterministic stress profile, not a simulated end-user device.
|
||||||
|
|
||||||
|
Benchmarks do not assert machine-dependent performance budgets. Streaming processes 160 deltas by default and reports renderer-observed completion time, throughput, RAF callback-gap distributions, frame-budget equivalents, and long tasks through final geometry settlement. Delta count and delivery batch are included in result context when overridden. These are main-thread callback diagnostics, not compositor presentation or dropped-frame measurements. Visual-only and geometry metrics are `null` when their probes are disabled. Tab metrics describe sampled DOM observations. Assertions verify scenario and metric collection completion. Repeated repaint states are run-length grouped, but every original observation timestamp is retained alongside raw mutation batches and layout shifts.
|
||||||
|
|
||||||
|
Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing.
|
||||||
|
|
||||||
|
## Chrome traces
|
||||||
|
|
||||||
|
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
OPENCODE_PERFORMANCE_TRACE_DIR=/tmp/opencode-performance-traces \
|
||||||
|
bunx playwright test --config e2e/performance/playwright.config.ts \
|
||||||
|
timeline/session-tab-switch-benchmark.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
The emitted JSON is a standard Chrome trace and can be loaded directly into the Chrome DevTools Performance panel. `devtools-tracing` can optionally inspect it from the command line without adding package scripts or dependencies:
|
||||||
|
|
||||||
|
Trace capture mirrors [Puppeteer's official tracing defaults and lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bunx devtools-tracing stats <trace-path-from-BENCHMARK_PAGE>
|
||||||
|
```
|
||||||
|
|
||||||
|
INP analysis requires a trace with a supported navigation/interaction insight. Selector statistics require a trace captured with `OPENCODE_PERFORMANCE_SELECTOR_TRACE=1`.
|
||||||
|
|
||||||
|
`e2e/performance/playwright.uncapped.config.ts` disables Chromium frame-rate limiting for explicit uncapped diagnostics. Native product benchmarks should use the default Playwright configuration.
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { expect, test as base, type Browser, type Page, type TestInfo } from "@playwright/test"
|
||||||
|
import { startChromeTrace } from "./chrome-trace"
|
||||||
|
|
||||||
|
type BenchmarkFixtures = {
|
||||||
|
report: (metrics: Record<string, unknown>, context?: Record<string, unknown>) => void
|
||||||
|
reportState: { payload?: { metrics: Record<string, unknown>; context: Record<string, unknown> } }
|
||||||
|
benchmarkResult: void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PerformancePageDiagnostics = {
|
||||||
|
navigations: string[]
|
||||||
|
stop: () => Promise<string | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = new WeakMap<Page, PerformancePageDiagnostics>()
|
||||||
|
|
||||||
|
export const benchmark = base.extend<BenchmarkFixtures>({
|
||||||
|
reportState: async ({}, use) => use({}),
|
||||||
|
report: async ({ reportState }, use) => {
|
||||||
|
await use((metrics, context = {}) => {
|
||||||
|
if (reportState.payload) throw new Error("Benchmark reported metrics more than once")
|
||||||
|
reportState.payload = { metrics, context }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
benchmarkResult: [
|
||||||
|
async ({ reportState }, use, testInfo) => {
|
||||||
|
await use()
|
||||||
|
const missing = !reportState.payload
|
||||||
|
console.log(
|
||||||
|
`BENCHMARK ${JSON.stringify({
|
||||||
|
schemaVersion: 2,
|
||||||
|
runID: process.env.OPENCODE_PERFORMANCE_RUN_ID,
|
||||||
|
name: benchmarkName(testInfo),
|
||||||
|
status: missing ? "failed" : testInfo.status,
|
||||||
|
expectedStatus: testInfo.expectedStatus,
|
||||||
|
retry: testInfo.retry,
|
||||||
|
repeatEachIndex: testInfo.repeatEachIndex,
|
||||||
|
context: {
|
||||||
|
project: testInfo.project.name,
|
||||||
|
platform: process.platform,
|
||||||
|
...reportState.payload?.context,
|
||||||
|
},
|
||||||
|
metrics: reportState.payload?.metrics ?? null,
|
||||||
|
error: missing ? "Benchmark did not report metrics" : undefined,
|
||||||
|
})}`,
|
||||||
|
)
|
||||||
|
if (missing && testInfo.status === testInfo.expectedStatus)
|
||||||
|
throw new Error(`Benchmark did not report metrics: ${benchmarkName(testInfo)}`)
|
||||||
|
},
|
||||||
|
{ auto: true },
|
||||||
|
],
|
||||||
|
page: async ({ page }, use, testInfo) => {
|
||||||
|
const name = benchmarkName(testInfo)
|
||||||
|
const diagnostics = await observePerformancePage(page, name)
|
||||||
|
try {
|
||||||
|
await use(page)
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await reportPerformancePage(name, diagnostics, testInfo)
|
||||||
|
} finally {
|
||||||
|
if (testInfo.status !== testInfo.expectedStatus) {
|
||||||
|
await testInfo.attach("performance-navigations", {
|
||||||
|
body: JSON.stringify(diagnostics.navigations, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function benchmarkName(testInfo: TestInfo) {
|
||||||
|
return testInfo.titlePath.slice(1).join(" > ")
|
||||||
|
}
|
||||||
|
|
||||||
|
export { expect }
|
||||||
|
|
||||||
|
async function observePerformancePage(page: Page, name: string) {
|
||||||
|
const navigations: string[] = []
|
||||||
|
const onNavigation = (frame: ReturnType<Page["mainFrame"]>) => {
|
||||||
|
if (frame === page.mainFrame()) navigations.push(frame.url())
|
||||||
|
}
|
||||||
|
page.on("framenavigated", onNavigation)
|
||||||
|
const stopTrace = await startChromeTrace(page, name).catch((error) => {
|
||||||
|
page.off("framenavigated", onNavigation)
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
let stopping: Promise<string | undefined> | undefined
|
||||||
|
const diagnostics: PerformancePageDiagnostics = {
|
||||||
|
navigations,
|
||||||
|
stop() {
|
||||||
|
page.off("framenavigated", onNavigation)
|
||||||
|
return (stopping ??= stopTrace?.() ?? Promise.resolve(undefined))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
pages.set(page, diagnostics)
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withBenchmarkPage<T>(
|
||||||
|
browser: Browser,
|
||||||
|
name: string,
|
||||||
|
run: (page: Page) => Promise<T>,
|
||||||
|
testInfo?: TestInfo,
|
||||||
|
) {
|
||||||
|
const context = await browser.newContext()
|
||||||
|
try {
|
||||||
|
const page = await context.newPage()
|
||||||
|
const diagnostics = await observePerformancePage(page, name)
|
||||||
|
try {
|
||||||
|
return await run(page)
|
||||||
|
} finally {
|
||||||
|
await reportPerformancePage(name, diagnostics, testInfo)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await context.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reportPerformancePage(name: string, diagnostics: PerformancePageDiagnostics, testInfo?: TestInfo) {
|
||||||
|
const trace = await diagnostics.stop()
|
||||||
|
console.log(
|
||||||
|
`BENCHMARK_PAGE ${JSON.stringify({
|
||||||
|
schemaVersion: 2,
|
||||||
|
runID: process.env.OPENCODE_PERFORMANCE_RUN_ID,
|
||||||
|
name,
|
||||||
|
test: testInfo ? benchmarkName(testInfo) : undefined,
|
||||||
|
retry: testInfo?.retry,
|
||||||
|
repeatEachIndex: testInfo?.repeatEachIndex,
|
||||||
|
context: {
|
||||||
|
platform: process.platform,
|
||||||
|
trace,
|
||||||
|
selectorTrace: process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1",
|
||||||
|
},
|
||||||
|
navigations: diagnostics.navigations,
|
||||||
|
})}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function benchmarkDiagnostics(page: Page) {
|
||||||
|
const diagnostics = pages.get(page)
|
||||||
|
if (!diagnostics) throw new Error("Performance diagnostics are not installed for this page")
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { CDPSession, Page } from "@playwright/test"
|
||||||
|
import path from "node:path"
|
||||||
|
import { mkdir, open, rename } from "node:fs/promises"
|
||||||
|
import { Buffer } from "node:buffer"
|
||||||
|
import { createHash, randomUUID } from "node:crypto"
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
"-*",
|
||||||
|
"devtools.timeline",
|
||||||
|
"v8.execute",
|
||||||
|
"disabled-by-default-devtools.timeline",
|
||||||
|
"disabled-by-default-devtools.timeline.frame",
|
||||||
|
"toplevel",
|
||||||
|
"blink.console",
|
||||||
|
"blink.user_timing",
|
||||||
|
"latencyInfo",
|
||||||
|
"disabled-by-default-devtools.timeline.stack",
|
||||||
|
"disabled-by-default-v8.cpu_profiler",
|
||||||
|
]
|
||||||
|
|
||||||
|
export async function startChromeTrace(page: Page, name: string) {
|
||||||
|
const directory = process.env.OPENCODE_PERFORMANCE_TRACE_DIR
|
||||||
|
if (!directory) return
|
||||||
|
|
||||||
|
const selectors = process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1"
|
||||||
|
const file = await prepareChromeTrace(directory, name, selectors)
|
||||||
|
const session = await page.context().newCDPSession(page)
|
||||||
|
try {
|
||||||
|
await session.send("Tracing.start", {
|
||||||
|
transferMode: "ReturnAsStream",
|
||||||
|
traceConfig: {
|
||||||
|
excludedCategories: categories
|
||||||
|
.filter((category) => category.startsWith("-"))
|
||||||
|
.map((category) => category.slice(1)),
|
||||||
|
includedCategories: [
|
||||||
|
...categories.filter((category) => !category.startsWith("-")),
|
||||||
|
...(selectors
|
||||||
|
? ["disabled-by-default-blink.debug", "disabled-by-default-devtools.timeline.invalidationTracking"]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
await Promise.allSettled([session.detach()])
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
let stopping: Promise<string> | undefined
|
||||||
|
|
||||||
|
return () =>
|
||||||
|
(stopping ??= (async () => {
|
||||||
|
try {
|
||||||
|
const complete = new Promise<{ stream?: string; dataLossOccurred: boolean }>((resolve) =>
|
||||||
|
session.once("Tracing.tracingComplete", resolve),
|
||||||
|
)
|
||||||
|
await session.send("Tracing.end")
|
||||||
|
const result = await complete
|
||||||
|
if (!result.stream) throw new Error(`Chrome trace stream missing: ${file}`)
|
||||||
|
const partial = `${file}.partial`
|
||||||
|
await writeProtocolStream(session, result.stream, partial)
|
||||||
|
if (result.dataLossOccurred) throw new Error(`Chrome trace lost data; partial capture retained: ${partial}`)
|
||||||
|
await rename(partial, file)
|
||||||
|
return file
|
||||||
|
} finally {
|
||||||
|
await Promise.allSettled([session.detach()])
|
||||||
|
}
|
||||||
|
})())
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareChromeTrace(
|
||||||
|
directory: string,
|
||||||
|
name: string,
|
||||||
|
selectors: boolean,
|
||||||
|
nonce = randomUUID().slice(0, 8),
|
||||||
|
) {
|
||||||
|
await mkdir(directory, { recursive: true })
|
||||||
|
const run = process.env.OPENCODE_PERFORMANCE_RUN_ID ?? "manual"
|
||||||
|
const hash = createHash("sha256").update(name).digest("hex").slice(0, 8)
|
||||||
|
return path.join(
|
||||||
|
directory,
|
||||||
|
`${run}-${name.replace(/[^a-zA-Z0-9_-]/g, "-")}-${hash}-${nonce}${selectors ? "-selectors" : ""}.json`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeProtocolStream(session: CDPSession, handle: string, file: string) {
|
||||||
|
const output = await open(file, "wx")
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const chunk = await session.send("IO.read", { handle })
|
||||||
|
await output.write(chunk.base64Encoded ? Buffer.from(chunk.data, "base64") : chunk.data)
|
||||||
|
if (chunk.eof) break
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await Promise.allSettled([output.close(), session.send("IO.close", { handle })])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import config from "../../playwright.config"
|
||||||
|
|
||||||
|
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
|
||||||
|
process.env.PLAYWRIGHT_SERVER_PORT = String(port)
|
||||||
|
process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}`
|
||||||
|
|
||||||
|
export default {
|
||||||
|
...config,
|
||||||
|
testDir: ".",
|
||||||
|
testIgnore: "unit/**",
|
||||||
|
outputDir: "../test-results/performance",
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
reporter: [["html", { outputFolder: "../playwright-report/performance", open: "never" }], ["line"]],
|
||||||
|
webServer: {
|
||||||
|
...config.webServer,
|
||||||
|
command: `bun run build && bun run serve -- --host 0.0.0.0 --port ${port} --strictPort`,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import config from "./playwright.config"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
...config,
|
||||||
|
outputDir: "../test-results/performance-uncapped",
|
||||||
|
reporter: [["html", { outputFolder: "../playwright-report/performance-uncapped", open: "never" }], ["line"]],
|
||||||
|
use: {
|
||||||
|
...config.use,
|
||||||
|
launchOptions: {
|
||||||
|
args: ["--disable-frame-rate-limit", "--disable-gpu-vsync"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { expectSessionTitle } from "../../utils/waits"
|
||||||
|
import { benchmark, expect } from "../benchmark"
|
||||||
|
import { measureFirstNavigation } from "./first-navigation-probe"
|
||||||
|
import { fixture } from "./session-timeline-stress.fixture"
|
||||||
|
import {
|
||||||
|
installStressSessionTabs,
|
||||||
|
installTimelineSettings,
|
||||||
|
mockStressTimeline,
|
||||||
|
stressDraftHref,
|
||||||
|
stressSessionHref,
|
||||||
|
} from "./timeline-test-helpers"
|
||||||
|
import { waitForStableTimeline } from "./session-tab-switch-probe"
|
||||||
|
|
||||||
|
const contentSelector = '[data-message-id], [data-component="prompt-input"]'
|
||||||
|
const draftID = "draft_first_navigation"
|
||||||
|
|
||||||
|
benchmark.describe("performance: first navigation paint", () => {
|
||||||
|
benchmark("opens an unvisited session tab without a blank frame", async ({ page, report }) => {
|
||||||
|
await setup(page)
|
||||||
|
const href = stressSessionHref(fixture.targetID)
|
||||||
|
const result = await measureFirstNavigation(page, {
|
||||||
|
href,
|
||||||
|
destinationPath: href,
|
||||||
|
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
|
||||||
|
destinationSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
|
||||||
|
contentSelector,
|
||||||
|
navigate: async () => {
|
||||||
|
await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click()
|
||||||
|
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
report(result)
|
||||||
|
expect(result.summary.blankSamples).toBe(0)
|
||||||
|
expect(result.summary.unknownSamples).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
benchmark("opens the new session page before its lazy module is used", async ({ page, report }) => {
|
||||||
|
await setup(page, draftID)
|
||||||
|
const href = stressDraftHref(draftID)
|
||||||
|
const result = await measureFirstNavigation(page, {
|
||||||
|
href,
|
||||||
|
destinationPath: href,
|
||||||
|
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
|
||||||
|
destinationSelector: '[data-component="prompt-input"]',
|
||||||
|
contentSelector,
|
||||||
|
navigate: async () => {
|
||||||
|
await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click()
|
||||||
|
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
report(result)
|
||||||
|
expect(result.summary.blankSamples).toBe(0)
|
||||||
|
expect(result.summary.unknownSamples).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
benchmark("opens a child session without a blank frame", async ({ page, report }) => {
|
||||||
|
await setup(page)
|
||||||
|
const href = stressSessionHref(fixture.childID)
|
||||||
|
const result = await measureFirstNavigation(page, {
|
||||||
|
href,
|
||||||
|
destinationPath: href,
|
||||||
|
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
|
||||||
|
destinationSelector: messageSelector(fixture.expected.childMessageIDs.at(-1)!),
|
||||||
|
contentSelector,
|
||||||
|
navigate: async () => {
|
||||||
|
await page.locator(`a[href="${href}"]`, { has: page.locator('[data-component="task-tool-card"]') }).click()
|
||||||
|
await expectSessionTitle(page, fixture.expected.childTitle)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
report(result)
|
||||||
|
expect(result.summary.blankSamples).toBe(0)
|
||||||
|
expect(result.summary.unknownSamples).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function setup(page: Parameters<typeof mockStressTimeline>[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}"]`
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export type FirstNavigationSample = {
|
||||||
|
observedAtMs: number
|
||||||
|
source: boolean
|
||||||
|
destination: boolean
|
||||||
|
content: boolean
|
||||||
|
pathname?: string
|
||||||
|
center?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function category(sample: FirstNavigationSample) {
|
||||||
|
if (sample.destination && !sample.source) return "destination"
|
||||||
|
if (sample.source && !sample.destination) return "source"
|
||||||
|
if (!sample.content) return "blank"
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeFirstNavigation(samples: FirstNavigationSample[]) {
|
||||||
|
const categories = samples.map(category)
|
||||||
|
const stable = categories.findIndex(
|
||||||
|
(value, index) =>
|
||||||
|
value === "destination" && categories[index + 1] === "destination" && categories[index + 2] === "destination",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
samples: samples.length,
|
||||||
|
firstDestinationObservedMs: samples[categories.indexOf("destination")]?.observedAtMs ?? null,
|
||||||
|
stableDestinationObservedMs: stable === -1 ? null : samples[stable + 2]!.observedAtMs,
|
||||||
|
sourceSamples: categories.filter((value) => value === "source").length,
|
||||||
|
blankSamples: categories.filter((value) => value === "blank").length,
|
||||||
|
unknownSamples: categories.filter((value) => value === "unknown").length,
|
||||||
|
destinationSamples: categories.filter((value) => value === "destination").length,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { summarizeFirstNavigation, type FirstNavigationSample } from "./first-navigation-metrics"
|
||||||
|
|
||||||
|
type FirstNavigationProbe = {
|
||||||
|
samples: FirstNavigationSample[]
|
||||||
|
stop: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function measureFirstNavigation(
|
||||||
|
page: Page,
|
||||||
|
input: {
|
||||||
|
href: string
|
||||||
|
destinationPath: string
|
||||||
|
sourceSelector: string
|
||||||
|
destinationSelector: string
|
||||||
|
contentSelector: string
|
||||||
|
navigate: () => Promise<void>
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
await page.evaluate(
|
||||||
|
({ href, destinationPath, sourceSelector, destinationSelector, contentSelector }) => {
|
||||||
|
const samples: FirstNavigationSample[] = []
|
||||||
|
let started: number | undefined
|
||||||
|
let running = true
|
||||||
|
const visible = (selector: string) =>
|
||||||
|
[...document.querySelectorAll<HTMLElement>(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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { benchmark, expect } from "../benchmark"
|
||||||
|
import { expectSessionTitle } from "../../utils/waits"
|
||||||
|
import { measureNavigationMilestones } from "./navigation-milestones"
|
||||||
|
import { fixture } from "./session-timeline-stress.fixture"
|
||||||
|
import {
|
||||||
|
installStressSessionTabs,
|
||||||
|
installTimelineSettings,
|
||||||
|
mockStressTimeline,
|
||||||
|
stressSessionHref,
|
||||||
|
} from "./timeline-test-helpers"
|
||||||
|
import { waitForStableTimeline } from "./session-tab-switch-probe"
|
||||||
|
|
||||||
|
const homeRow = '[data-component="home-session-row"]'
|
||||||
|
const homeShell = '[data-component="home-session-search"]'
|
||||||
|
|
||||||
|
benchmark.describe("performance: home and tab navigation", () => {
|
||||||
|
benchmark("opens a home session and paints its titlebar tab", async ({ page, report }) => {
|
||||||
|
await setup(page, [])
|
||||||
|
await page.goto("/")
|
||||||
|
const row = page.locator(homeRow).filter({ hasText: fixture.expected.targetTitle }).first()
|
||||||
|
await expect(row).toBeVisible()
|
||||||
|
const href = stressSessionHref(fixture.targetID)
|
||||||
|
const result = await measureNavigationMilestones(page, {
|
||||||
|
triggerSelector: homeRow,
|
||||||
|
milestones: {
|
||||||
|
content: { selector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!) },
|
||||||
|
tab: { selector: `[data-slot="titlebar-tabs"] a[href="${href}"]` },
|
||||||
|
},
|
||||||
|
navigate: async () => {
|
||||||
|
await row.click()
|
||||||
|
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
report(result)
|
||||||
|
await expect(page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`)).toContainText(
|
||||||
|
fixture.expected.targetTitle,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
benchmark("stages the review body after cold session content", async ({ page, report }) => {
|
||||||
|
await setup(page, [])
|
||||||
|
await page.goto("/")
|
||||||
|
const row = page.locator(homeRow).filter({ hasText: fixture.expected.targetTitle }).first()
|
||||||
|
await expect(row).toBeVisible()
|
||||||
|
const result = await page.evaluate(
|
||||||
|
({ rowSelector, title, contentSelector }) =>
|
||||||
|
new Promise<{ contentBeforeReview: boolean; samples: number }>((resolve) => {
|
||||||
|
let samples = 0
|
||||||
|
const sample = () => {
|
||||||
|
samples++
|
||||||
|
const content = !!document.querySelector(contentSelector)
|
||||||
|
const review = !!document.querySelector('[data-component="session-review"]')
|
||||||
|
if (content && !review) {
|
||||||
|
resolve({ contentBeforeReview: true, samples })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (content && review) {
|
||||||
|
resolve({ contentBeforeReview: false, samples })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestAnimationFrame(sample)
|
||||||
|
}
|
||||||
|
const target = [...document.querySelectorAll<HTMLElement>(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<typeof mockStressTimeline>[0], sessionIDs: string[]) {
|
||||||
|
await mockStressTimeline(page)
|
||||||
|
await installTimelineSettings(page)
|
||||||
|
await installStressSessionTabs(page, { sessionIDs })
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageSelector(id: string) {
|
||||||
|
return `[data-message-id="${id}"]`
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
|
||||||
|
export type NavigationMilestoneSample = {
|
||||||
|
observedAtMs: number
|
||||||
|
milestones: Record<string, boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, { selector: string; visible?: boolean }>
|
||||||
|
navigate: () => Promise<void>
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
await page.evaluate(
|
||||||
|
({ triggerSelector, milestones }) => {
|
||||||
|
const samples: NavigationMilestoneSample[] = []
|
||||||
|
const streaks = new Map<string, number>()
|
||||||
|
const marked = new Set<string>()
|
||||||
|
let started: number | undefined
|
||||||
|
let running = true
|
||||||
|
const visible = (selector: string) =>
|
||||||
|
[...document.querySelectorAll<HTMLElement>(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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { benchmark, expect } from "../benchmark"
|
||||||
|
import { expectSessionTitle } from "../../utils/waits"
|
||||||
|
import { fixture } from "./session-timeline-stress.fixture"
|
||||||
|
import {
|
||||||
|
collectCachedRepaintTrace,
|
||||||
|
compressCachedRepaintTrace,
|
||||||
|
installCachedRepaintProbe,
|
||||||
|
waitForCachedRepaintWindow,
|
||||||
|
} from "./session-tab-repaint-probe"
|
||||||
|
import { waitForStableTimeline } from "./session-tab-switch-probe"
|
||||||
|
import {
|
||||||
|
installStressSessionTabs,
|
||||||
|
installTimelineSettings,
|
||||||
|
mockStressTimeline,
|
||||||
|
stressSessionHref,
|
||||||
|
} from "./timeline-test-helpers"
|
||||||
|
|
||||||
|
benchmark("samples cached session repaint after the click", async ({ page, report }) => {
|
||||||
|
benchmark.setTimeout(120_000)
|
||||||
|
await mockStressTimeline(page)
|
||||||
|
await installStressSessionTabs(page)
|
||||||
|
await installTimelineSettings(page)
|
||||||
|
await page.goto(stressSessionHref(fixture.targetID))
|
||||||
|
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||||
|
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
|
||||||
|
await page
|
||||||
|
.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.sourceID)}"]`)
|
||||||
|
.first()
|
||||||
|
.click()
|
||||||
|
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||||
|
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||||
|
|
||||||
|
await installCachedRepaintProbe(page, {
|
||||||
|
targetHref: stressSessionHref(fixture.targetID),
|
||||||
|
destination: fixture.messages[fixture.targetID].map((message) => message.info.id),
|
||||||
|
source: fixture.messages[fixture.sourceID].map((message) => message.info.id),
|
||||||
|
last: fixture.expected.targetMessageIDs.at(-1)!,
|
||||||
|
windowMs: 1_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.targetID)}"]`)
|
||||||
|
.first()
|
||||||
|
.click()
|
||||||
|
await Promise.all([expectSessionTitle(page, fixture.expected.targetTitle), waitForCachedRepaintWindow(page, 1_000)])
|
||||||
|
const result = await collectCachedRepaintTrace(page)
|
||||||
|
report(compressCachedRepaintTrace(result))
|
||||||
|
expect(result.samples.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
benchmark("prefetches every open session tab", async ({ page, report }) => {
|
||||||
|
const prefetched = new Set<string>()
|
||||||
|
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] })
|
||||||
|
})
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
|
||||||
|
type CachedRepaintTrace = {
|
||||||
|
timeOriginEpochMs: number
|
||||||
|
startedAtPerformanceMs: number
|
||||||
|
samples: {
|
||||||
|
observedAtMs: number
|
||||||
|
root: number | undefined
|
||||||
|
scrollTop: number
|
||||||
|
scrollHeight: number
|
||||||
|
bottomErrorPx: number | undefined
|
||||||
|
last: boolean
|
||||||
|
rows: { key: string | undefined; node: number; top: number; bottom: number }[]
|
||||||
|
mounted: number
|
||||||
|
center: string | undefined
|
||||||
|
destination: string[]
|
||||||
|
source: string[]
|
||||||
|
}[]
|
||||||
|
mutations: { observedAtMs: number; changed: { type: string; node: number }[] }[]
|
||||||
|
shifts: { occurredAtMs: number; value: number }[]
|
||||||
|
windowMs: number
|
||||||
|
running: boolean
|
||||||
|
stop: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installCachedRepaintProbe(
|
||||||
|
page: Page,
|
||||||
|
input: { targetHref: string; destination: string[]; source: string[]; last: string; windowMs: number },
|
||||||
|
) {
|
||||||
|
await page.evaluate(({ targetHref, destination, source, last, windowMs }) => {
|
||||||
|
const destinationIDs = new Set(destination)
|
||||||
|
const sourceIDs = new Set(source)
|
||||||
|
const nodeIDs = new WeakMap<Node, number>()
|
||||||
|
let nextNodeID = 1
|
||||||
|
const id = (node: Node) => {
|
||||||
|
const current = nodeIDs.get(node)
|
||||||
|
if (current) return current
|
||||||
|
nodeIDs.set(node, nextNodeID)
|
||||||
|
return nextNodeID++
|
||||||
|
}
|
||||||
|
const state: CachedRepaintTrace = {
|
||||||
|
timeOriginEpochMs: performance.timeOrigin,
|
||||||
|
startedAtPerformanceMs: 0,
|
||||||
|
samples: [],
|
||||||
|
mutations: [],
|
||||||
|
shifts: [],
|
||||||
|
windowMs,
|
||||||
|
running: false,
|
||||||
|
stop: () => {},
|
||||||
|
}
|
||||||
|
const recordShifts = (entries: PerformanceEntry[]) => {
|
||||||
|
if (!state.running) return
|
||||||
|
state.shifts.push(
|
||||||
|
...entries
|
||||||
|
.map((entry) => {
|
||||||
|
if (
|
||||||
|
entry.startTime < state.startedAtPerformanceMs ||
|
||||||
|
entry.startTime > state.startedAtPerformanceMs + state.windowMs
|
||||||
|
)
|
||||||
|
return
|
||||||
|
return {
|
||||||
|
occurredAtMs: entry.startTime - state.startedAtPerformanceMs,
|
||||||
|
value: (entry as PerformanceEntry & { value: number }).value,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((entry): entry is { occurredAtMs: number; value: number } => entry !== undefined),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const shiftObserver = new PerformanceObserver((entries) => recordShifts(entries.getEntries()))
|
||||||
|
shiftObserver.observe({ type: "layout-shift" })
|
||||||
|
const recordMutations = (entries: MutationRecord[]) => {
|
||||||
|
if (!state.running) return
|
||||||
|
const observedAtMs = performance.now() - state.startedAtPerformanceMs
|
||||||
|
if (observedAtMs > state.windowMs) return
|
||||||
|
const changed = entries.flatMap((entry) => [
|
||||||
|
...[...entry.addedNodes].map((node) => ({ type: "add", node: id(node) })),
|
||||||
|
...[...entry.removedNodes].map((node) => ({ type: "remove", node: id(node) })),
|
||||||
|
])
|
||||||
|
if (changed.length) state.mutations.push({ observedAtMs, changed })
|
||||||
|
}
|
||||||
|
const mutationObserver = new MutationObserver(recordMutations)
|
||||||
|
mutationObserver.observe(document.documentElement, { childList: true, subtree: true })
|
||||||
|
state.stop = () => {
|
||||||
|
recordShifts(shiftObserver.takeRecords())
|
||||||
|
recordMutations(mutationObserver.takeRecords())
|
||||||
|
state.running = false
|
||||||
|
shiftObserver.disconnect()
|
||||||
|
mutationObserver.disconnect()
|
||||||
|
}
|
||||||
|
const sample = () => {
|
||||||
|
if (!state.running) return
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!state.running) return
|
||||||
|
const observedAtMs = performance.now() - state.startedAtPerformanceMs
|
||||||
|
if (observedAtMs > state.windowMs) return
|
||||||
|
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||||
|
element.querySelector("[data-timeline-row]"),
|
||||||
|
)
|
||||||
|
if (root) {
|
||||||
|
const view = root.getBoundingClientRect()
|
||||||
|
const rows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
|
||||||
|
.map((element) => ({
|
||||||
|
key: element.dataset.timelineKey,
|
||||||
|
node: id(element),
|
||||||
|
rect: element.getBoundingClientRect(),
|
||||||
|
}))
|
||||||
|
.filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom)
|
||||||
|
.map((item) => ({
|
||||||
|
key: item.key,
|
||||||
|
node: item.node,
|
||||||
|
top: item.rect.top - view.top,
|
||||||
|
bottom: item.rect.bottom - view.top,
|
||||||
|
}))
|
||||||
|
const messages = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
|
||||||
|
.filter((element) => {
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
return rect.bottom > view.top && rect.top < view.bottom
|
||||||
|
})
|
||||||
|
.map((element) => element.dataset.messageId!)
|
||||||
|
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
|
||||||
|
state.samples.push({
|
||||||
|
observedAtMs,
|
||||||
|
root: id(root),
|
||||||
|
scrollTop: root.scrollTop,
|
||||||
|
scrollHeight: root.scrollHeight,
|
||||||
|
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
|
||||||
|
last: messages.includes(last),
|
||||||
|
rows,
|
||||||
|
mounted: root.querySelectorAll("[data-timeline-key]").length,
|
||||||
|
center: document
|
||||||
|
.elementFromPoint(view.left + view.width / 2, view.top + view.height / 2)
|
||||||
|
?.textContent?.slice(0, 80),
|
||||||
|
destination: messages.filter((messageID) => destinationIDs.has(messageID)),
|
||||||
|
source: messages.filter((messageID) => sourceIDs.has(messageID)),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
state.samples.push({
|
||||||
|
observedAtMs,
|
||||||
|
root: undefined,
|
||||||
|
scrollTop: 0,
|
||||||
|
scrollHeight: 0,
|
||||||
|
bottomErrorPx: undefined,
|
||||||
|
last: false,
|
||||||
|
rows: [],
|
||||||
|
mounted: 0,
|
||||||
|
center: document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.textContent?.slice(0, 80),
|
||||||
|
destination: [],
|
||||||
|
source: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
requestAnimationFrame(sample)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
document.addEventListener(
|
||||||
|
"click",
|
||||||
|
(event) => {
|
||||||
|
const link = event.target instanceof Element ? event.target.closest("a") : undefined
|
||||||
|
if (link?.getAttribute("href") !== targetHref) return
|
||||||
|
state.startedAtPerformanceMs = performance.now()
|
||||||
|
state.running = true
|
||||||
|
requestAnimationFrame(sample)
|
||||||
|
},
|
||||||
|
{ capture: true, once: true },
|
||||||
|
)
|
||||||
|
;(window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash = state
|
||||||
|
}, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function layoutShiftSample(entry: Pick<PerformanceEntry, "startTime"> & { value: number }, started: number) {
|
||||||
|
if (entry.startTime < started) return
|
||||||
|
return { occurredAtMs: entry.startTime - started, value: entry.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForCachedRepaintWindow(page: Page, durationMs: number) {
|
||||||
|
await page.waitForFunction((durationMs) => {
|
||||||
|
const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash
|
||||||
|
return !!state?.running && performance.now() - state.startedAtPerformanceMs >= durationMs
|
||||||
|
}, durationMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectCachedRepaintTrace(page: Page) {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash!
|
||||||
|
state.stop()
|
||||||
|
return state
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeCachedRepaintTrace(trace: CachedRepaintTrace) {
|
||||||
|
const roots = trace.samples.map((sample) => sample.root)
|
||||||
|
const bottomErrors = trace.samples.flatMap((sample) =>
|
||||||
|
sample.bottomErrorPx === undefined ? [] : [Math.abs(sample.bottomErrorPx)],
|
||||||
|
)
|
||||||
|
const category = (sample: CachedRepaintTrace["samples"][number]) => {
|
||||||
|
if (sample.source.length) return "source"
|
||||||
|
if (sample.root === undefined || sample.rows.length === 0) return "blank"
|
||||||
|
if (!sample.destination.length) return "unknown"
|
||||||
|
if (sample.last && Math.abs(sample.bottomErrorPx ?? Infinity) <= 1) return "correct"
|
||||||
|
return "wrongDestination"
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
samples: trace.samples.length,
|
||||||
|
durationMs: trace.samples.at(-1)?.observedAtMs ?? 0,
|
||||||
|
firstSampleObservedMs: trace.samples[0]?.observedAtMs,
|
||||||
|
firstSampleCorrect: trace.samples[0] ? category(trace.samples[0]) === "correct" : false,
|
||||||
|
blankSamples: trace.samples.filter((sample) => category(sample) === "blank").length,
|
||||||
|
sourceSamples: trace.samples.filter((sample) => category(sample) === "source").length,
|
||||||
|
wrongDestinationSamples: trace.samples.filter((sample) => category(sample) === "wrongDestination").length,
|
||||||
|
unknownSamples: trace.samples.filter((sample) => category(sample) === "unknown").length,
|
||||||
|
rootChanges: roots.slice(1).filter((root, index) => root !== roots[index]).length,
|
||||||
|
mountedMin: trace.samples.length ? Math.min(...trace.samples.map((sample) => sample.mounted)) : 0,
|
||||||
|
mountedMax: Math.max(...trace.samples.map((sample) => sample.mounted)),
|
||||||
|
maxBottomErrorPx: Math.max(0, ...bottomErrors),
|
||||||
|
mutationBatches: trace.mutations.length,
|
||||||
|
addedNodes: trace.mutations.reduce(
|
||||||
|
(sum, batch) => sum + batch.changed.filter((change) => change.type === "add").length,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
removedNodes: trace.mutations.reduce(
|
||||||
|
(sum, batch) => sum + batch.changed.filter((change) => change.type === "remove").length,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
layoutShiftValueSum: trace.shifts.reduce((sum, shift) => sum + shift.value, 0),
|
||||||
|
maxLayoutShiftValue: Math.max(0, ...trace.shifts.map((shift) => shift.value)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compressCachedRepaintTrace(trace: CachedRepaintTrace) {
|
||||||
|
const samples: {
|
||||||
|
observedAtMs: number[]
|
||||||
|
state: Omit<CachedRepaintTrace["samples"][number], "observedAtMs">
|
||||||
|
}[] = []
|
||||||
|
for (const sample of trace.samples) {
|
||||||
|
const { observedAtMs, ...state } = sample
|
||||||
|
const previous = samples.at(-1)
|
||||||
|
if (previous && JSON.stringify(previous.state) === JSON.stringify(state)) {
|
||||||
|
previous.observedAtMs.push(observedAtMs)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
samples.push({ observedAtMs: [observedAtMs], state })
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
timeOriginEpochMs: trace.timeOriginEpochMs,
|
||||||
|
startedAtPerformanceMs: trace.startedAtPerformanceMs,
|
||||||
|
windowMs: trace.windowMs,
|
||||||
|
summary: summarizeCachedRepaintTrace(trace),
|
||||||
|
samples,
|
||||||
|
mutations: trace.mutations,
|
||||||
|
shifts: trace.shifts,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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 { installStressSessionTabs, mockStressTimeline, stressSessionHref } from "./timeline-test-helpers"
|
||||||
|
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
|
||||||
|
|
||||||
|
type Result = Awaited<ReturnType<typeof measureSessionSwitch>>
|
||||||
|
|
||||||
|
benchmark("benchmarks cold and hot session tab switching", async ({ browser, report }, testInfo) => {
|
||||||
|
benchmark.setTimeout(180_000)
|
||||||
|
const results = { cold: [] as Result[], hot: [] as Result[] }
|
||||||
|
for (const mode of ["cold", "hot"] as const) {
|
||||||
|
for (let run = 0; run < 5; run++) {
|
||||||
|
results[mode].push(
|
||||||
|
await withBenchmarkPage(browser, `session-tab-switch-${mode}-${run}`, (page) => trial(page, mode), testInfo),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report({ results, summary: summarize(results) })
|
||||||
|
})
|
||||||
|
|
||||||
|
async function trial(page: Page, mode: "cold" | "hot") {
|
||||||
|
await mockStressTimeline(page)
|
||||||
|
await installStressSessionTabs(page)
|
||||||
|
if (mode === "hot") {
|
||||||
|
await page.goto(stressSessionHref(fixture.targetID))
|
||||||
|
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||||
|
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
|
||||||
|
await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
||||||
|
} else {
|
||||||
|
await page.goto(stressSessionHref(fixture.sourceID))
|
||||||
|
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||||
|
}
|
||||||
|
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||||
|
|
||||||
|
const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.info.id)
|
||||||
|
const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.info.id)
|
||||||
|
const lastID = fixture.expected.targetMessageIDs.at(-1)!
|
||||||
|
const href = stressSessionHref(fixture.targetID)
|
||||||
|
const result = await measureSessionSwitch(page, {
|
||||||
|
destinationIDs,
|
||||||
|
sourceIDs,
|
||||||
|
lastID,
|
||||||
|
href,
|
||||||
|
switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(results: Record<"cold" | "hot", Result[]>) {
|
||||||
|
const stats = (values: (number | null)[]) => {
|
||||||
|
const sorted = values.filter((value): value is number => value !== null).sort((a, b) => a - b)
|
||||||
|
return {
|
||||||
|
min: sorted[0] ?? null,
|
||||||
|
median: sorted[Math.floor(sorted.length / 2)] ?? null,
|
||||||
|
max: sorted.at(-1) ?? null,
|
||||||
|
missing: values.length - sorted.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(results).map(([mode, values]) => [
|
||||||
|
mode,
|
||||||
|
{
|
||||||
|
firstDestinationObservedMs: stats(values.map((value) => value.firstDestinationObservedMs)),
|
||||||
|
firstCorrectObservedMs: stats(values.map((value) => value.firstCorrectObservedMs)),
|
||||||
|
stableObservedMs: stats(values.map((value) => value.stableObservedMs)),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchSession(page: Page, sessionID: string, title: string) {
|
||||||
|
const href = stressSessionHref(sessionID)
|
||||||
|
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
export type SessionSwitchSample = {
|
||||||
|
observedAtMs: number
|
||||||
|
destination: string[]
|
||||||
|
source: string[]
|
||||||
|
hasVisibleRows: boolean
|
||||||
|
last: boolean
|
||||||
|
bottomErrorPx?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifySessionSwitch(samples: SessionSwitchSample[]) {
|
||||||
|
const firstDestination = samples.findIndex((sample) => sample.destination.length > 0)
|
||||||
|
const firstCorrect = samples.findIndex(isCorrectDestination)
|
||||||
|
const stable = samples.findIndex((_, index) => isStableSessionSwitch(samples.slice(index, index + 3)))
|
||||||
|
return {
|
||||||
|
firstDestinationObservedMs: samples[firstDestination]?.observedAtMs ?? null,
|
||||||
|
firstCorrectObservedMs: samples[firstCorrect]?.observedAtMs ?? null,
|
||||||
|
stableObservedMs: samples[stable + 2]?.observedAtMs ?? null,
|
||||||
|
wrongDestinationSamples: samples
|
||||||
|
.slice(firstDestination)
|
||||||
|
.filter((sample) => sample.destination.length > 0 && !sample.last).length,
|
||||||
|
blankSamples: samples.filter((sample) => !sample.hasVisibleRows).length,
|
||||||
|
unknownSamples: samples.filter(
|
||||||
|
(sample) => sample.hasVisibleRows && sample.destination.length === 0 && sample.source.length === 0,
|
||||||
|
).length,
|
||||||
|
sourceSamples: samples.filter((sample) => sample.source.length > 0).length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCorrectDestination(sample: SessionSwitchSample) {
|
||||||
|
return (
|
||||||
|
sample.destination.length > 0 &&
|
||||||
|
sample.source.length === 0 &&
|
||||||
|
sample.last &&
|
||||||
|
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStableSessionSwitch(samples: SessionSwitchSample[]) {
|
||||||
|
return samples.length === 3 && samples.every(isCorrectDestination)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStableDestination(samples: Pick<SessionSwitchSample, "last" | "bottomErrorPx">[]) {
|
||||||
|
return (
|
||||||
|
samples.length === 3 && samples.every((sample) => sample.last && Math.abs(sample.bottomErrorPx ?? Infinity) <= 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test"
|
||||||
|
import { classifySessionSwitch, isStableDestination, type SessionSwitchSample } from "./session-tab-switch-metrics"
|
||||||
|
|
||||||
|
type SessionSwitchProbe = {
|
||||||
|
samples: SessionSwitchSample[]
|
||||||
|
stop: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installSessionSwitchProbe(
|
||||||
|
page: Page,
|
||||||
|
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string },
|
||||||
|
) {
|
||||||
|
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 sample = () => {
|
||||||
|
if (!running || started === undefined) return
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!running || started === undefined) return
|
||||||
|
const observedAtMs = performance.now() - started
|
||||||
|
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||||
|
element.querySelector("[data-timeline-row]"),
|
||||||
|
)
|
||||||
|
if (root) {
|
||||||
|
const view = root.getBoundingClientRect()
|
||||||
|
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
|
||||||
|
.filter((element) => {
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
return rect.bottom > view.top && rect.top < view.bottom
|
||||||
|
})
|
||||||
|
.map((element) => element.dataset.messageId!)
|
||||||
|
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some((element) => {
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
return rect.bottom > view.top && rect.top < view.bottom
|
||||||
|
})
|
||||||
|
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
|
||||||
|
samples.push({
|
||||||
|
observedAtMs,
|
||||||
|
destination: visible.filter((id) => destination.has(id)),
|
||||||
|
source: visible.filter((id) => source.has(id)),
|
||||||
|
hasVisibleRows,
|
||||||
|
last: visible.includes(lastID),
|
||||||
|
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false })
|
||||||
|
}
|
||||||
|
requestAnimationFrame(sample)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
document.addEventListener(
|
||||||
|
"click",
|
||||||
|
(event) => {
|
||||||
|
const link = event.target instanceof Element ? event.target.closest("a") : undefined
|
||||||
|
if (link?.getAttribute("href") !== href) return
|
||||||
|
started = performance.now()
|
||||||
|
requestAnimationFrame(sample)
|
||||||
|
},
|
||||||
|
{ capture: true, once: true },
|
||||||
|
)
|
||||||
|
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
|
||||||
|
samples,
|
||||||
|
stop: () => {
|
||||||
|
running = false
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForStableSessionSwitch(page: Page) {
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const samples = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.samples
|
||||||
|
if (!samples) return false
|
||||||
|
return samples.some((_, index) => {
|
||||||
|
const stable = samples.slice(index, index + 3)
|
||||||
|
return (
|
||||||
|
stable.length === 3 &&
|
||||||
|
stable.every(
|
||||||
|
(sample) =>
|
||||||
|
sample.destination.length > 0 &&
|
||||||
|
sample.source.length === 0 &&
|
||||||
|
sample.last &&
|
||||||
|
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectSessionSwitchResult(page: Page) {
|
||||||
|
const samples = await page.evaluate(() => {
|
||||||
|
const probe = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe!
|
||||||
|
probe.stop()
|
||||||
|
return probe.samples
|
||||||
|
})
|
||||||
|
return classifySessionSwitch(samples)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function measureSessionSwitch(
|
||||||
|
page: Page,
|
||||||
|
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string; switch: () => Promise<void> },
|
||||||
|
) {
|
||||||
|
const { switch: run, ...probe } = input
|
||||||
|
await installSessionSwitchProbe(page, probe)
|
||||||
|
await run()
|
||||||
|
await waitForStableSessionSwitch(page)
|
||||||
|
return collectSessionSwitchResult(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForStableTimeline(page: Page, lastID: string) {
|
||||||
|
const samples: Pick<SessionSwitchSample, "last" | "bottomErrorPx">[] = []
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
samples.push(
|
||||||
|
await page.evaluate(
|
||||||
|
(lastID) =>
|
||||||
|
new Promise<Pick<SessionSwitchSample, "last" | "bottomErrorPx">>((resolve) => {
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
setTimeout(() => {
|
||||||
|
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||||
|
element.querySelector("[data-timeline-row]"),
|
||||||
|
)
|
||||||
|
if (!root) {
|
||||||
|
resolve({ last: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const view = root.getBoundingClientRect()
|
||||||
|
const last = [...root.querySelectorAll<HTMLElement>("[data-message-id]")].some((element) => {
|
||||||
|
if (element.dataset.messageId !== lastID) return false
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
return rect.bottom > view.top && rect.top < view.bottom
|
||||||
|
})
|
||||||
|
const spacer = root
|
||||||
|
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
|
||||||
|
?.getBoundingClientRect()
|
||||||
|
resolve({ last, bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined })
|
||||||
|
}, 0),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
lastID,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return isStableDestination(samples.slice(-3))
|
||||||
|
},
|
||||||
|
{ timeout: 30_000, intervals: [0] },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
}
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||||
|
import { expectAppVisible, expectSessionTitle } from "../../utils/waits"
|
||||||
|
import { expect } from "../benchmark"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/TimelineStateRegression"
|
||||||
|
const projectID = "proj_timeline_state_regression"
|
||||||
|
const sessionID = "ses_timeline_state_regression"
|
||||||
|
const userMessageID = "msg_user_regression"
|
||||||
|
const assistantMessageID = "msg_assistant_regression"
|
||||||
|
const editPartID = "prt_0001_edit"
|
||||||
|
export const textPartID = "prt_9999_text"
|
||||||
|
const title = "Timeline collapse state regression"
|
||||||
|
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||||
|
|
||||||
|
type EventPayload = {
|
||||||
|
directory: string
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMessage = {
|
||||||
|
info: {
|
||||||
|
id: userMessageID,
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1700000000000 },
|
||||||
|
summary: { diffs: [] },
|
||||||
|
agent: "build",
|
||||||
|
model,
|
||||||
|
},
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
id: "prt_user_text",
|
||||||
|
sessionID,
|
||||||
|
messageID: userMessageID,
|
||||||
|
type: "text",
|
||||||
|
text: "Please edit the file.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const editPart = {
|
||||||
|
id: editPartID,
|
||||||
|
sessionID,
|
||||||
|
messageID: assistantMessageID,
|
||||||
|
type: "tool",
|
||||||
|
callID: "call_edit_regression",
|
||||||
|
tool: "edit",
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: { filePath: "src/regression.ts" },
|
||||||
|
output: "Edited src/regression.ts",
|
||||||
|
title: "src/regression.ts",
|
||||||
|
metadata: {
|
||||||
|
filediff: {
|
||||||
|
file: "src/regression.ts",
|
||||||
|
additions: 1,
|
||||||
|
deletions: 1,
|
||||||
|
before: "export const value = 'before'\n",
|
||||||
|
after: "export const value = 'after'\n",
|
||||||
|
},
|
||||||
|
diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
|
||||||
|
},
|
||||||
|
time: { start: 1700000001000, end: 1700000002000 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamedTextPart = {
|
||||||
|
id: textPartID,
|
||||||
|
sessionID,
|
||||||
|
messageID: assistantMessageID,
|
||||||
|
type: "text",
|
||||||
|
text: "Streaming added a later assistant text part.",
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantMessage = {
|
||||||
|
info: {
|
||||||
|
id: assistantMessageID,
|
||||||
|
sessionID,
|
||||||
|
role: "assistant",
|
||||||
|
time: { created: 1700000001000 },
|
||||||
|
parentID: userMessageID,
|
||||||
|
modelID: model.modelID,
|
||||||
|
providerID: model.providerID,
|
||||||
|
mode: "build",
|
||||||
|
agent: "build",
|
||||||
|
path: { cwd: directory, root: directory },
|
||||||
|
cost: 0.01,
|
||||||
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
variant: "max",
|
||||||
|
},
|
||||||
|
parts: [editPart],
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setupTimelineBenchmark(page: Page, options: { historyTurns: number; eventBatch: number }) {
|
||||||
|
const events: EventPayload[] = []
|
||||||
|
let eventBatch = options.eventBatch
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: project(),
|
||||||
|
provider: provider(),
|
||||||
|
sessions: [session()],
|
||||||
|
pageMessages: () => ({
|
||||||
|
items: [
|
||||||
|
...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index)).flat(),
|
||||||
|
userMessage,
|
||||||
|
assistantMessage,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
events: () => events.splice(0, eventBatch),
|
||||||
|
eventRetry: 16,
|
||||||
|
})
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"settings.v3",
|
||||||
|
JSON.stringify({
|
||||||
|
general: {
|
||||||
|
editToolPartsExpanded: true,
|
||||||
|
shellToolPartsExpanded: true,
|
||||||
|
showReasoningSummaries: 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()
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
await expectAppVisible(scroller)
|
||||||
|
return {
|
||||||
|
scroller,
|
||||||
|
text,
|
||||||
|
transport: {
|
||||||
|
enqueue(payload: EventPayload | EventPayload[]) {
|
||||||
|
events.push(...(Array.isArray(payload) ? payload : [payload]))
|
||||||
|
},
|
||||||
|
pendingCount() {
|
||||||
|
return events.length
|
||||||
|
},
|
||||||
|
releaseAll() {
|
||||||
|
eventBatch = events.length
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async scrollToBottom() {
|
||||||
|
await scroller.evaluate((element) => {
|
||||||
|
element.scrollTop = element.scrollHeight
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async waitForStableGeometry() {
|
||||||
|
await expect
|
||||||
|
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||||
|
.toBeLessThanOrEqual(1)
|
||||||
|
await page.waitForFunction((partID) => {
|
||||||
|
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||||
|
element.querySelector(`[data-timeline-part-id="${partID}"]`),
|
||||||
|
)
|
||||||
|
if (!root) return false
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
const height = root.scrollHeight
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
resolve(root.scrollHeight === height && root.scrollHeight - root.clientHeight - root.scrollTop <= 1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, textPartID)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInitialStreamEvent(deltaCount: number): EventPayload {
|
||||||
|
return {
|
||||||
|
directory,
|
||||||
|
payload: {
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
part: {
|
||||||
|
...streamedTextPart,
|
||||||
|
text: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStreamDeltaEvents(deltaCount: number): EventPayload[] {
|
||||||
|
return Array.from({ length: deltaCount }, (_, index) => ({
|
||||||
|
directory,
|
||||||
|
payload: {
|
||||||
|
type: "message.part.delta",
|
||||||
|
properties: {
|
||||||
|
messageID: assistantMessageID,
|
||||||
|
partID: textPartID,
|
||||||
|
field: "text",
|
||||||
|
delta: streamChunk(index + 1, deltaCount + 1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function performanceTurn(index: number) {
|
||||||
|
const suffix = String(index).padStart(4, "0")
|
||||||
|
const userID = `msg_0000_${suffix}_a_user`
|
||||||
|
const assistantID = `msg_0000_${suffix}_b_assistant`
|
||||||
|
const before = historicalSource(index, false)
|
||||||
|
const after = historicalSource(index, true)
|
||||||
|
const parts = [
|
||||||
|
...(index % 5 === 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: `prt_0000_${suffix}_reasoning`,
|
||||||
|
sessionID,
|
||||||
|
messageID: assistantID,
|
||||||
|
type: "reasoning",
|
||||||
|
text: `Reviewing the existing implementation. ${"constraint analysis ".repeat(20)}`,
|
||||||
|
time: { start: 1690000001000 + index * 2_000, end: 1690000001200 + index * 2_000 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
id: `prt_0000_${suffix}_assistant`,
|
||||||
|
sessionID,
|
||||||
|
messageID: assistantID,
|
||||||
|
type: "text",
|
||||||
|
text: historicalMarkdown(index),
|
||||||
|
},
|
||||||
|
...(index % 8 === 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: `prt_0000_${suffix}_edit`,
|
||||||
|
sessionID,
|
||||||
|
messageID: assistantID,
|
||||||
|
type: "tool",
|
||||||
|
callID: `call_0000_${suffix}_edit`,
|
||||||
|
tool: "edit",
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: { filePath: `src/history-${index}.ts` },
|
||||||
|
output: `Edited src/history-${index}.ts`,
|
||||||
|
title: `src/history-${index}.ts`,
|
||||||
|
metadata: {
|
||||||
|
filediff: { file: `src/history-${index}.ts`, additions: 48, deletions: 48, before, after },
|
||||||
|
},
|
||||||
|
time: { start: 1690000001200 + index * 2_000, end: 1690000001400 + index * 2_000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(index % 12 === 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: `prt_0000_${suffix}_write`,
|
||||||
|
sessionID,
|
||||||
|
messageID: assistantID,
|
||||||
|
type: "tool",
|
||||||
|
callID: `call_0000_${suffix}_write`,
|
||||||
|
tool: "write",
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: { filePath: `src/generated-${index}.tsx`, content: after },
|
||||||
|
output: `Wrote src/generated-${index}.tsx`,
|
||||||
|
title: `src/generated-${index}.tsx`,
|
||||||
|
metadata: {
|
||||||
|
filediff: { file: `src/generated-${index}.tsx`, additions: 32, deletions: 0, before: "", after },
|
||||||
|
},
|
||||||
|
time: { start: 1690000001400 + index * 2_000, end: 1690000001500 + index * 2_000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(index % 16 === 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: `prt_0000_${suffix}_patch`,
|
||||||
|
sessionID,
|
||||||
|
messageID: assistantID,
|
||||||
|
type: "tool",
|
||||||
|
callID: `call_0000_${suffix}_patch`,
|
||||||
|
tool: "apply_patch",
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: { patchText: realisticPatch(index) },
|
||||||
|
output: "Success. Updated src/components/SessionCard.tsx",
|
||||||
|
title: "src/components/SessionCard.tsx",
|
||||||
|
metadata: {
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
filePath: "src/components/SessionCard.tsx",
|
||||||
|
relativePath: "src/components/SessionCard.tsx",
|
||||||
|
type: "update",
|
||||||
|
additions: 8,
|
||||||
|
deletions: 3,
|
||||||
|
patch: realisticPatch(index),
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
time: { start: 1690000001500 + index * 2_000, end: 1690000001700 + index * 2_000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
id: userID,
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1690000000000 + index * 2_000 },
|
||||||
|
summary: { diffs: [] },
|
||||||
|
agent: "build",
|
||||||
|
model,
|
||||||
|
},
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
id: `prt_0000_${suffix}_user`,
|
||||||
|
sessionID,
|
||||||
|
messageID: userID,
|
||||||
|
type: "text",
|
||||||
|
text: `Historical prompt ${index}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
id: assistantID,
|
||||||
|
sessionID,
|
||||||
|
role: "assistant",
|
||||||
|
time: { created: 1690000001000 + index * 2_000, completed: 1690000001500 + index * 2_000 },
|
||||||
|
parentID: userID,
|
||||||
|
modelID: model.modelID,
|
||||||
|
providerID: model.providerID,
|
||||||
|
mode: "build",
|
||||||
|
agent: "build",
|
||||||
|
path: { cwd: directory, root: directory },
|
||||||
|
cost: 0.01,
|
||||||
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
variant: "max",
|
||||||
|
finish: "stop",
|
||||||
|
},
|
||||||
|
parts,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function historicalMarkdown(index: number) {
|
||||||
|
const code = `import { For, Show, createSignal } from "solid-js"
|
||||||
|
|
||||||
|
type SessionRow = { id: string; title: string; active: boolean }
|
||||||
|
|
||||||
|
export function SessionList(props: { rows: SessionRow[] }) {
|
||||||
|
const [selected, setSelected] = createSignal<string>()
|
||||||
|
return (
|
||||||
|
<section aria-label="Sessions">
|
||||||
|
<For each={props.rows}>{(row) => (
|
||||||
|
<button classList={{ active: row.active }} onClick={() => setSelected(row.id)}>
|
||||||
|
<Show when={selected() === row.id} fallback={row.title}>{row.title.toUpperCase()}</Show>
|
||||||
|
</button>
|
||||||
|
)}</For>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}`
|
||||||
|
return `## Session renderer review ${index}
|
||||||
|
|
||||||
|
The active session keeps **semantic row identity** while reconciling measured content. See [Solid documentation](https://docs.solidjs.com/) and the inline \`measureElement(node)\` call.
|
||||||
|
|
||||||
|
| Concern | Current behavior | Verification |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| streaming | appends Markdown blocks | painted frames |
|
||||||
|
| geometry | anchors visible rows | DOM coordinates |
|
||||||
|
| tools | preserves expanded state | keyed remount probe |
|
||||||
|
|
||||||
|
> Long sessions combine Markdown, syntax highlighting, tool output, and asynchronously rendered diffs.
|
||||||
|
|
||||||
|
${index % 4 === 0 ? `\`\`\`tsx\n${code}\n\`\`\`\n\n\`\`\`bash\nbun typecheck\nbun test --preload ./happydom.ts ./src/pages/session\ngit diff --check\n\`\`\`` : "- preserve the viewport anchor\n- avoid replacing stable Markdown nodes\n- process provider deltas without blocking input"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function historicalSource(index: number, updated: boolean) {
|
||||||
|
const method = updated ? "toLocaleUpperCase(props.locale)" : "toUpperCase()"
|
||||||
|
const limit = updated ? 24 : 20
|
||||||
|
return `import { createMemo, For } from "solid-js"
|
||||||
|
|
||||||
|
type Message = {
|
||||||
|
id: string
|
||||||
|
role: "user" | "assistant"
|
||||||
|
text: string
|
||||||
|
tokens: { input: number; output: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageSummary(props: { messages: Message[]; locale: string }) {
|
||||||
|
const visible = createMemo(() => props.messages.filter((message) => message.text.trim()).slice(-${limit}))
|
||||||
|
const total = createMemo(() => visible().reduce((sum, message) => sum + message.tokens.output, 0))
|
||||||
|
return (
|
||||||
|
<article data-session-index="${index}">
|
||||||
|
<header>{total().toLocaleString(props.locale)} output tokens</header>
|
||||||
|
<For each={visible()}>{(message) => <p data-role={message.role}>{message.text.${method}}</p>}</For>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function realisticPatch(index: number) {
|
||||||
|
return `*** Begin Patch
|
||||||
|
*** Update File: src/components/SessionCard.tsx
|
||||||
|
@@
|
||||||
|
-const title = props.session.title.toUpperCase()
|
||||||
|
-const messages = props.messages.slice(-20)
|
||||||
|
+const title = props.session.title.toLocaleUpperCase(props.locale)
|
||||||
|
+const messages = props.messages.filter((message) => message.text.trim()).slice(-24)
|
||||||
|
+const outputTokens = messages.reduce((sum, message) => sum + message.tokens.output, 0)
|
||||||
|
@@
|
||||||
|
- <h2>{title}</h2>
|
||||||
|
+ <h2 data-session-index="${index}">{title}</h2>
|
||||||
|
+ <span>{outputTokens.toLocaleString(props.locale)} output tokens</span>
|
||||||
|
*** End Patch`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function streamChunk(index: number, count: number) {
|
||||||
|
if (index === 0) return `\n\n## Implementation plan\n\nStreaming **bold analysis`
|
||||||
|
if (index === count - 1)
|
||||||
|
return `\n\`\`\`\n\n## Verification\n\n- **Typecheck:** passed\n- **Timeline geometry:** stable\n- **Streaming output:** benchmark-complete <!-- stream-${index} -->`
|
||||||
|
|
||||||
|
const section = Math.floor(index / 18) + 1
|
||||||
|
const fragments = [
|
||||||
|
` continues across three`,
|
||||||
|
` or four word`,
|
||||||
|
` provider deltas and`,
|
||||||
|
` closes in this fragment**. <!-- stream-${index} -->\n\n`,
|
||||||
|
`| Concern | State`,
|
||||||
|
` | Verification |\n|`,
|
||||||
|
` --- | ---`,
|
||||||
|
` | --- |\n|`,
|
||||||
|
` markdown | incremental |`,
|
||||||
|
` painted frames | <!-- stream-${index} -->\n\n`,
|
||||||
|
`\`\`\`tsx\nconst row: SessionRow`,
|
||||||
|
` = rows[index] ??`,
|
||||||
|
` fallback\nconst title =`,
|
||||||
|
` row.title.toLocaleUpperCase(locale)\n`,
|
||||||
|
`const selected = createMemo(()`,
|
||||||
|
` => row.id ===`,
|
||||||
|
` activeID()) // stream-${index}\n`,
|
||||||
|
`// stream-${index}\n\`\`\`\n\n### Iteration ${section}\n\nStreaming **bold analysis`,
|
||||||
|
]
|
||||||
|
return fragments[(index - 1) % fragments.length]!
|
||||||
|
}
|
||||||
|
|
||||||
|
function project() {
|
||||||
|
return {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "timeline-state-regression",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function session() {
|
||||||
|
return {
|
||||||
|
id: sessionID,
|
||||||
|
slug: "timeline-state-regression",
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function provider() {
|
||||||
|
return {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
|
||||||
|
import {
|
||||||
|
buildInitialStreamEvent,
|
||||||
|
buildStreamDeltaEvents,
|
||||||
|
setupTimelineBenchmark,
|
||||||
|
textPartID,
|
||||||
|
} from "./session-timeline-benchmark.fixture"
|
||||||
|
import { startTimelineProfile } from "./session-timeline-profile"
|
||||||
|
import {
|
||||||
|
collectTimelineStreamMetrics,
|
||||||
|
installTimelineStreamProbe,
|
||||||
|
startTimelineStreamProbe,
|
||||||
|
} from "./session-timeline-stream-probe"
|
||||||
|
|
||||||
|
benchmark.describe("performance: session timeline streaming", () => {
|
||||||
|
benchmark("streams assistant text without remounting or oscillating", async ({ page, report }) => {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
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 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(
|
||||||
|
{
|
||||||
|
endToEndInitialContentObservedMs: initialContentObservedMs,
|
||||||
|
...metrics,
|
||||||
|
deliveredDeltas: delivered,
|
||||||
|
pendingDeltas: fixture.transport.pendingCount(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cpuThrottle,
|
||||||
|
profileCPU,
|
||||||
|
profileVisual,
|
||||||
|
minimal,
|
||||||
|
queuedDeltas: deltas.length,
|
||||||
|
historyTurns,
|
||||||
|
eventBatch,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await profile.reset()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { CDPSession, Page } from "@playwright/test"
|
||||||
|
|
||||||
|
export async function startTimelineProfile(page: Page, options: { cpuThrottle: number; profileCPU: boolean }) {
|
||||||
|
const cdp = await page.context().newCDPSession(page)
|
||||||
|
if (options.cpuThrottle > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: options.cpuThrottle })
|
||||||
|
if (options.profileCPU) {
|
||||||
|
await cdp.send("Profiler.enable")
|
||||||
|
await cdp.send("Profiler.setSamplingInterval", { interval: 100 })
|
||||||
|
await cdp.send("Profiler.start")
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
async stop() {
|
||||||
|
if (!options.profileCPU) return
|
||||||
|
const result = await cdp.send("Profiler.stop")
|
||||||
|
const self = new Map<number, number>()
|
||||||
|
result.profile.samples?.forEach((id, index) => {
|
||||||
|
const duration = (result.profile.timeDeltas?.[index] ?? 0) / 1_000
|
||||||
|
self.set(id, (self.get(id) ?? 0) + duration)
|
||||||
|
})
|
||||||
|
console.log(
|
||||||
|
"timeline cpu profile",
|
||||||
|
JSON.stringify(
|
||||||
|
result.profile.nodes
|
||||||
|
.map((node) => ({
|
||||||
|
function: node.callFrame.functionName || "(anonymous)",
|
||||||
|
url: node.callFrame.url,
|
||||||
|
line: node.callFrame.lineNumber + 1,
|
||||||
|
selfMs: self.get(node.id) ?? 0,
|
||||||
|
}))
|
||||||
|
.filter((node) => node.selfMs > 1)
|
||||||
|
.sort((a, b) => b.selfMs - a.selfMs)
|
||||||
|
.slice(0, 40),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
async reset() {
|
||||||
|
if (options.cpuThrottle > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: 1 })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
|
||||||
|
const STREAM_MARKER_PATTERN = "stream-(\\d+)"
|
||||||
|
const STREAM_FRAGMENT_COUNT = 18
|
||||||
|
|
||||||
|
type TimelineProbeState = {
|
||||||
|
started: number
|
||||||
|
ended: number
|
||||||
|
profileVisual: boolean
|
||||||
|
minimal: boolean
|
||||||
|
frames: number[]
|
||||||
|
frameAt: number[]
|
||||||
|
applied: { at: number; index: number }[]
|
||||||
|
geometry: {
|
||||||
|
scrollTop: number
|
||||||
|
scrollHeight: number
|
||||||
|
clientHeight: number
|
||||||
|
distance: number
|
||||||
|
virtualHeight: number
|
||||||
|
headerHeight: number
|
||||||
|
}[]
|
||||||
|
blanks: number
|
||||||
|
longTasks: number[]
|
||||||
|
layoutShifts: number[]
|
||||||
|
visibleMounts: number
|
||||||
|
visibleUnmounts: number
|
||||||
|
visibleRows: Set<Element>
|
||||||
|
visibleSubtreeMounts: string[]
|
||||||
|
visibleSubtreeUnmounts: string[]
|
||||||
|
visibleSubtreeReplacements: number
|
||||||
|
visibleSubtreeDropouts: string[]
|
||||||
|
visibleSubtrees: Map<string, Element>
|
||||||
|
subtreeKeys: WeakMap<Element, string>
|
||||||
|
maxOverlap: number
|
||||||
|
maxGap: number
|
||||||
|
maxPartTopMovement: number
|
||||||
|
previousPartTop: number
|
||||||
|
slowFrames: {
|
||||||
|
duration: number
|
||||||
|
index: number
|
||||||
|
phase: "stream" | "boundary" | "complete" | "unknown"
|
||||||
|
tokenSpans: number
|
||||||
|
blocks: number
|
||||||
|
codeBlocks: number
|
||||||
|
height: number
|
||||||
|
distance: number
|
||||||
|
}[]
|
||||||
|
scroll: {
|
||||||
|
calls: number
|
||||||
|
callNoops: number
|
||||||
|
sameFrameCalls: number
|
||||||
|
assignments: number
|
||||||
|
assignmentNoops: number
|
||||||
|
lastCallFrame: number
|
||||||
|
frame: number
|
||||||
|
}
|
||||||
|
row: HTMLElement
|
||||||
|
markdown: HTMLElement
|
||||||
|
running: boolean
|
||||||
|
previous: number
|
||||||
|
cleanup: () => void
|
||||||
|
start: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installTimelineStreamProbe(
|
||||||
|
page: Page,
|
||||||
|
options: { textPartID: string; finalIndex: number; profileVisual: boolean; minimal: boolean },
|
||||||
|
) {
|
||||||
|
await page.evaluate(
|
||||||
|
({ textPartID, finalIndex, profileVisual, minimal, markerPattern, fragmentCount }) => {
|
||||||
|
const part = document.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
|
||||||
|
const row = part?.closest<HTMLElement>("[data-timeline-row]")
|
||||||
|
const markdown = part?.querySelector<HTMLElement>('[data-component="markdown"]')
|
||||||
|
const root = part?.closest<HTMLElement>(".scroll-view__viewport")
|
||||||
|
if (!part || !row || !markdown || !root) throw new Error("missing streaming benchmark nodes")
|
||||||
|
const viewport = root.getBoundingClientRect()
|
||||||
|
const state: TimelineProbeState = {
|
||||||
|
started: 0,
|
||||||
|
ended: Infinity,
|
||||||
|
profileVisual,
|
||||||
|
minimal,
|
||||||
|
frames: [],
|
||||||
|
frameAt: [],
|
||||||
|
applied: [],
|
||||||
|
geometry: [],
|
||||||
|
blanks: 0,
|
||||||
|
longTasks: [],
|
||||||
|
layoutShifts: [],
|
||||||
|
visibleMounts: 0,
|
||||||
|
visibleUnmounts: 0,
|
||||||
|
visibleRows: new Set(
|
||||||
|
[...root.querySelectorAll("[data-timeline-key]")].filter((element) => {
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
return rect.bottom > viewport.top && rect.top < viewport.bottom
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
visibleSubtreeMounts: [],
|
||||||
|
visibleSubtreeUnmounts: [],
|
||||||
|
visibleSubtreeReplacements: 0,
|
||||||
|
visibleSubtreeDropouts: [],
|
||||||
|
visibleSubtrees: new Map<string, Element>(),
|
||||||
|
subtreeKeys: new WeakMap<Element, string>(),
|
||||||
|
maxOverlap: 0,
|
||||||
|
maxGap: 0,
|
||||||
|
maxPartTopMovement: 0,
|
||||||
|
previousPartTop: part.getBoundingClientRect().top,
|
||||||
|
slowFrames: [],
|
||||||
|
scroll: {
|
||||||
|
calls: 0,
|
||||||
|
callNoops: 0,
|
||||||
|
sameFrameCalls: 0,
|
||||||
|
assignments: 0,
|
||||||
|
assignmentNoops: 0,
|
||||||
|
lastCallFrame: -1,
|
||||||
|
frame: 0,
|
||||||
|
},
|
||||||
|
row,
|
||||||
|
markdown,
|
||||||
|
running: false,
|
||||||
|
previous: 0,
|
||||||
|
cleanup: () => {},
|
||||||
|
start: () => {},
|
||||||
|
}
|
||||||
|
;(window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark = state
|
||||||
|
const scrollTo = Element.prototype.scrollTo
|
||||||
|
const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")!
|
||||||
|
if (profileVisual) {
|
||||||
|
Element.prototype.scrollTo = function (...args) {
|
||||||
|
state.scroll.calls += 1
|
||||||
|
const top = typeof args[0] === "object" ? args[0]?.top : args[1]
|
||||||
|
if (typeof top === "number") {
|
||||||
|
const target = Math.min(top, this.scrollHeight - this.clientHeight)
|
||||||
|
if (Math.abs(this.scrollTop - target) < 1) state.scroll.callNoops += 1
|
||||||
|
}
|
||||||
|
if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1
|
||||||
|
state.scroll.lastCallFrame = state.scroll.frame
|
||||||
|
return scrollTo.apply(this, args)
|
||||||
|
}
|
||||||
|
Object.defineProperty(Element.prototype, "scrollTop", {
|
||||||
|
configurable: true,
|
||||||
|
get: scrollTop.get,
|
||||||
|
set(value) {
|
||||||
|
state.scroll.assignments += 1
|
||||||
|
if (Math.abs(this.scrollTop - value) < 1) state.scroll.assignmentNoops += 1
|
||||||
|
scrollTop.set!.call(this, value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordLongTasks = (entries: PerformanceEntry[]) => {
|
||||||
|
if (!state.running) return
|
||||||
|
state.longTasks.push(
|
||||||
|
...entries
|
||||||
|
.filter((entry) => entry.startTime >= state.started && entry.startTime <= state.ended)
|
||||||
|
.map((entry) => entry.duration),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const longTaskObserver = new PerformanceObserver((list) => recordLongTasks(list.getEntries()))
|
||||||
|
longTaskObserver.observe({ type: "longtask" })
|
||||||
|
const recordLayoutShifts = (entries: PerformanceEntry[]) => {
|
||||||
|
if (!state.running) return
|
||||||
|
state.layoutShifts.push(
|
||||||
|
...entries
|
||||||
|
.map((entry) => {
|
||||||
|
const shift = entry as LayoutShiftEntry
|
||||||
|
if (shift.startTime < state.started || shift.hadRecentInput) return
|
||||||
|
return shift.value
|
||||||
|
})
|
||||||
|
.filter((value): value is number => value !== undefined),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const layoutShiftObserver = profileVisual
|
||||||
|
? new PerformanceObserver((list) => recordLayoutShifts(list.getEntries()))
|
||||||
|
: undefined
|
||||||
|
layoutShiftObserver?.observe({ type: "layout-shift", buffered: true })
|
||||||
|
|
||||||
|
const visible = (element: Element) => {
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
const viewport = root.getBoundingClientRect()
|
||||||
|
const style = getComputedStyle(element)
|
||||||
|
return (
|
||||||
|
element.isConnected &&
|
||||||
|
rect.width > 0 &&
|
||||||
|
rect.height > 0 &&
|
||||||
|
rect.bottom > viewport.top &&
|
||||||
|
rect.top < viewport.bottom &&
|
||||||
|
style.display !== "none" &&
|
||||||
|
style.visibility !== "hidden" &&
|
||||||
|
Number(style.opacity) > 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const critical = [
|
||||||
|
"[data-timeline-part-id]",
|
||||||
|
'[data-component="edit-content"]',
|
||||||
|
'[data-component="apply-patch-file-diff"]',
|
||||||
|
'[data-component="file"]',
|
||||||
|
'[data-component="markdown-code"]',
|
||||||
|
"[data-markdown-block]",
|
||||||
|
].join(",")
|
||||||
|
const describe = (element: Element) => {
|
||||||
|
const cached = state.subtreeKeys.get(element)
|
||||||
|
if (!element.isConnected && cached) return cached
|
||||||
|
const part = element.closest<HTMLElement>("[data-timeline-part-id]")?.dataset.timelinePartId ?? "unknown"
|
||||||
|
const block = element
|
||||||
|
.closest<HTMLElement>("[data-markdown-key]")
|
||||||
|
?.dataset.markdownKey?.replace(/:(?:code|full|live)$/, "")
|
||||||
|
const component =
|
||||||
|
element.getAttribute("data-component") ?? element.getAttribute("data-markdown-block") ?? element.tagName
|
||||||
|
const key = `${part}:${block ?? "root"}:${component}`
|
||||||
|
state.subtreeKeys.set(element, key)
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
const recordMutations = (records: MutationRecord[]) => {
|
||||||
|
if (!state.running) return
|
||||||
|
records.forEach((record) => {
|
||||||
|
record.addedNodes.forEach((node) => {
|
||||||
|
if (node instanceof HTMLElement && node.matches("[data-timeline-key]") && visible(node)) {
|
||||||
|
state.visibleMounts += 1
|
||||||
|
state.visibleRows.add(node)
|
||||||
|
}
|
||||||
|
if (!(node instanceof Element)) return
|
||||||
|
const added = [node, ...node.querySelectorAll(critical)].filter((element) => element.matches(critical))
|
||||||
|
added.forEach((element) => {
|
||||||
|
if (visible(element)) state.visibleSubtreeMounts.push(describe(element))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
record.removedNodes.forEach((node) => {
|
||||||
|
if (node instanceof HTMLElement && node.matches("[data-timeline-key]") && state.visibleRows.delete(node))
|
||||||
|
state.visibleUnmounts += 1
|
||||||
|
if (!(node instanceof Element)) return
|
||||||
|
const removed = [node, ...node.querySelectorAll(critical)].filter((element) => element.matches(critical))
|
||||||
|
removed.forEach((element) => {
|
||||||
|
const key = describe(element)
|
||||||
|
if (state.visibleSubtrees.get(key) === element) state.visibleSubtreeUnmounts.push(key)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const mutationObserver = profileVisual ? new MutationObserver(recordMutations) : undefined
|
||||||
|
mutationObserver?.observe(root, { childList: true, subtree: true })
|
||||||
|
const currentPart = () => root.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
|
||||||
|
const observeProgress = (at: number) => {
|
||||||
|
if (!state.running) return
|
||||||
|
const content = currentPart()?.textContent ?? ""
|
||||||
|
const index = content.includes("benchmark-complete")
|
||||||
|
? finalIndex
|
||||||
|
: Number(content.match(new RegExp(markerPattern, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
|
||||||
|
if (index >= 0 && index !== state.applied.at(-1)?.index) state.applied.push({ at, index })
|
||||||
|
}
|
||||||
|
const progressObserver = new MutationObserver(() => observeProgress(performance.now()))
|
||||||
|
progressObserver.observe(root, { characterData: true, childList: true, subtree: true })
|
||||||
|
state.cleanup = () => {
|
||||||
|
recordLongTasks(longTaskObserver.takeRecords())
|
||||||
|
recordLayoutShifts(layoutShiftObserver?.takeRecords() ?? [])
|
||||||
|
recordMutations(mutationObserver?.takeRecords() ?? [])
|
||||||
|
if (progressObserver.takeRecords().length) observeProgress(performance.now())
|
||||||
|
longTaskObserver.disconnect()
|
||||||
|
layoutShiftObserver?.disconnect()
|
||||||
|
mutationObserver?.disconnect()
|
||||||
|
progressObserver.disconnect()
|
||||||
|
if (!profileVisual) return
|
||||||
|
Element.prototype.scrollTo = scrollTo
|
||||||
|
Object.defineProperty(Element.prototype, "scrollTop", scrollTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sample = (now: number) => {
|
||||||
|
if (!state.running) return
|
||||||
|
state.frameAt.push(now)
|
||||||
|
observeProgress(now)
|
||||||
|
if (minimal) {
|
||||||
|
state.frames.push(now - state.previous)
|
||||||
|
state.previous = now
|
||||||
|
requestAnimationFrame(sample)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!state.running) return
|
||||||
|
state.scroll.frame += 1
|
||||||
|
const duration = now - state.previous
|
||||||
|
state.frames.push(duration)
|
||||||
|
state.previous = now
|
||||||
|
const virtualRoot = root.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
||||||
|
const header = root.querySelector<HTMLElement>("[data-session-title]")
|
||||||
|
state.geometry.push({
|
||||||
|
scrollTop: root.scrollTop,
|
||||||
|
scrollHeight: root.scrollHeight,
|
||||||
|
clientHeight: root.clientHeight,
|
||||||
|
distance: root.scrollHeight - root.clientHeight - root.scrollTop,
|
||||||
|
virtualHeight: virtualRoot?.getBoundingClientRect().height ?? 0,
|
||||||
|
headerHeight: header?.getBoundingClientRect().height ?? 0,
|
||||||
|
})
|
||||||
|
const viewport = root.getBoundingClientRect()
|
||||||
|
if (profileVisual) {
|
||||||
|
const visibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
|
||||||
|
.map((element) => ({ element, rect: element.getBoundingClientRect() }))
|
||||||
|
.filter((item) => item.rect.bottom > viewport.top && item.rect.top < viewport.bottom)
|
||||||
|
.sort((a, b) => a.rect.top - b.rect.top)
|
||||||
|
state.visibleRows = new Set(visibleRows.map((item) => item.element))
|
||||||
|
const rows = visibleRows.map((item) => item.rect)
|
||||||
|
rows.slice(1).forEach((rect, index) => {
|
||||||
|
const previous = rows[index]!
|
||||||
|
state.maxOverlap = Math.max(state.maxOverlap, previous.bottom - rect.top)
|
||||||
|
state.maxGap = Math.max(state.maxGap, rect.top - previous.bottom)
|
||||||
|
})
|
||||||
|
const partTop = part.getBoundingClientRect().top
|
||||||
|
state.maxPartTopMovement = Math.max(state.maxPartTopMovement, Math.abs(partTop - state.previousPartTop))
|
||||||
|
state.previousPartTop = partTop
|
||||||
|
}
|
||||||
|
const visibleRow = [...root.querySelectorAll<HTMLElement>("[data-timeline-row]")].some((element) => {
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
return rect.bottom > viewport.top && rect.top < viewport.bottom
|
||||||
|
})
|
||||||
|
if (!visibleRow) state.blanks += 1
|
||||||
|
if (profileVisual) {
|
||||||
|
const subtrees = new Map<string, { element: Element; rendered: boolean }>()
|
||||||
|
const visibleSubtrees = new Map<string, Element>()
|
||||||
|
root.querySelectorAll(critical).forEach((element) => {
|
||||||
|
const key = describe(element)
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
const style = getComputedStyle(element)
|
||||||
|
const rendered =
|
||||||
|
element.isConnected &&
|
||||||
|
rect.width > 0 &&
|
||||||
|
rect.height > 0 &&
|
||||||
|
style.display !== "none" &&
|
||||||
|
style.visibility !== "hidden" &&
|
||||||
|
Number(style.opacity) > 0
|
||||||
|
subtrees.set(key, { element, rendered })
|
||||||
|
if (rendered && rect.bottom > viewport.top && rect.top < viewport.bottom) {
|
||||||
|
const previous = state.visibleSubtrees.get(key)
|
||||||
|
if (previous && previous !== element && key.startsWith(`${textPartID}:`))
|
||||||
|
state.visibleSubtreeReplacements += 1
|
||||||
|
visibleSubtrees.set(key, element)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
state.visibleSubtrees.forEach((element, key) => {
|
||||||
|
const current = subtrees.get(key)
|
||||||
|
if (key.startsWith(`${textPartID}:`) && !current?.rendered) {
|
||||||
|
const markdown = part.querySelector<HTMLElement>('[data-component="markdown"]')
|
||||||
|
state.visibleSubtreeDropouts.push(
|
||||||
|
`${key}:projection=${markdown?.dataset.markdownProjectionLength}/${markdown?.dataset.markdownProjectionBlocks}:result=${markdown?.dataset.markdownResultLength}/${markdown?.dataset.markdownResultBlocks}:applied=${markdown?.dataset.markdownAppliedBlocks}:dom=${markdown?.children.length}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (element.matches('[data-component="file"]')) {
|
||||||
|
const hadLines = element.hasAttribute("data-profiler-had-lines")
|
||||||
|
const hasLines = element.shadowRoot?.querySelector("[data-line]") != null
|
||||||
|
if (hasLines) element.setAttribute("data-profiler-had-lines", "")
|
||||||
|
if (hadLines && !hasLines) state.visibleSubtreeDropouts.push(`${key}:shadow-lines`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
state.visibleSubtrees = visibleSubtrees
|
||||||
|
}
|
||||||
|
if (profileVisual && duration > 33.34) {
|
||||||
|
const livePart = currentPart()
|
||||||
|
const content = livePart?.textContent ?? ""
|
||||||
|
const complete = content.includes("benchmark-complete")
|
||||||
|
const index = complete
|
||||||
|
? finalIndex
|
||||||
|
: Number(content.match(new RegExp(markerPattern, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
|
||||||
|
state.slowFrames.push({
|
||||||
|
duration,
|
||||||
|
index,
|
||||||
|
phase: complete
|
||||||
|
? "complete"
|
||||||
|
: index >= 0 && index % fragmentCount === 0
|
||||||
|
? "boundary"
|
||||||
|
: index >= 0
|
||||||
|
? "stream"
|
||||||
|
: "unknown",
|
||||||
|
tokenSpans: livePart?.querySelectorAll(".shiki span").length ?? 0,
|
||||||
|
blocks: livePart?.querySelectorAll("[data-markdown-block]").length ?? 0,
|
||||||
|
codeBlocks: livePart?.querySelectorAll('[data-component="markdown-code"]').length ?? 0,
|
||||||
|
height: livePart?.getBoundingClientRect().height ?? 0,
|
||||||
|
distance: root.scrollHeight - root.clientHeight - root.scrollTop,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
requestAnimationFrame(sample)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
state.start = () => {
|
||||||
|
state.started = performance.now()
|
||||||
|
state.previous = state.started
|
||||||
|
state.running = true
|
||||||
|
requestAnimationFrame(sample)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ ...options, markerPattern: STREAM_MARKER_PATTERN, fragmentCount: STREAM_FRAGMENT_COUNT },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startTimelineStreamProbe(page: Page) {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark
|
||||||
|
if (!state) throw new Error("missing streaming benchmark state")
|
||||||
|
state.start()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type LayoutShiftEntry = PerformanceEntry & { value: number; hadRecentInput?: boolean }
|
||||||
|
|
||||||
|
export function layoutShiftValue(
|
||||||
|
entry: Pick<LayoutShiftEntry, "startTime" | "value" | "hadRecentInput">,
|
||||||
|
start: number,
|
||||||
|
) {
|
||||||
|
if (entry.startTime < start || entry.hadRecentInput) return
|
||||||
|
return entry.value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeVisibleRow<T>(visible: Set<T>, row: T) {
|
||||||
|
return visible.delete(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function streamProgress(content: string) {
|
||||||
|
const index = Number(content.match(new RegExp(STREAM_MARKER_PATTERN, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
phase: content.includes("benchmark-complete")
|
||||||
|
? ("complete" as const)
|
||||||
|
: index >= 0 && index % STREAM_FRAGMENT_COUNT === 0
|
||||||
|
? ("boundary" as const)
|
||||||
|
: index >= 0
|
||||||
|
? ("stream" as const)
|
||||||
|
: ("unknown" as const),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectTimelineStreamMetrics(
|
||||||
|
page: Page,
|
||||||
|
options: { textPartID: string; finalIndex: number; navigations: string[] },
|
||||||
|
) {
|
||||||
|
return page.evaluate(({ textPartID, finalIndex, navigations }) => {
|
||||||
|
const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark
|
||||||
|
if (!state) throw new Error(`missing streaming benchmark state after navigation: ${JSON.stringify(navigations)}`)
|
||||||
|
state.ended = performance.now()
|
||||||
|
state.cleanup()
|
||||||
|
state.running = false
|
||||||
|
const part = document.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
|
||||||
|
const row = part?.closest<HTMLElement>("[data-timeline-row]")
|
||||||
|
const markdown = part?.querySelector<HTMLElement>('[data-component="markdown"]')
|
||||||
|
const sorted = state.frames.slice().sort((a, b) => a - b)
|
||||||
|
const duration = state.frames.reduce((sum, value) => sum + value, 0)
|
||||||
|
const longestSlowStreak = state.frames.reduce(
|
||||||
|
(result, value) => {
|
||||||
|
const current = value > 33.34 ? result.current + 1 : 0
|
||||||
|
return { current, longest: Math.max(result.longest, current) }
|
||||||
|
},
|
||||||
|
{ current: 0, longest: 0 },
|
||||||
|
).longest
|
||||||
|
const busyStart = state.applied.at(0)?.at
|
||||||
|
const completion = state.applied.find((value) => value.index === finalIndex)
|
||||||
|
const busyEnd = completion?.at
|
||||||
|
const busyFrames =
|
||||||
|
busyStart === undefined || busyEnd === undefined
|
||||||
|
? []
|
||||||
|
: state.frames.filter((_, index) => state.frameAt[index]! >= busyStart && state.frameAt[index]! <= busyEnd)
|
||||||
|
const busySorted = busyFrames.slice().sort((a, b) => a - b)
|
||||||
|
const busyDuration = busyFrames.reduce((sum, value) => sum + value, 0)
|
||||||
|
const completionObservedMs = (completion?.at ?? NaN) - state.started
|
||||||
|
const visual = state.profileVisual
|
||||||
|
? {
|
||||||
|
layoutShiftValueSum: state.layoutShifts.reduce((sum, value) => sum + value, 0),
|
||||||
|
maxLayoutShiftValue: Math.max(0, ...state.layoutShifts),
|
||||||
|
visibleMounts: state.visibleMounts,
|
||||||
|
visibleUnmounts: state.visibleUnmounts,
|
||||||
|
visibleSubtreeMounts: state.visibleSubtreeMounts,
|
||||||
|
visibleSubtreeUnmounts: [...new Set(state.visibleSubtreeUnmounts)],
|
||||||
|
visibleSubtreeReplacements: state.visibleSubtreeReplacements,
|
||||||
|
visibleSubtreeDropouts: [...new Set(state.visibleSubtreeDropouts)],
|
||||||
|
maxOverlapPx: state.maxOverlap,
|
||||||
|
maxGapPx: state.maxGap,
|
||||||
|
maxPartTopMovementPx: state.maxPartTopMovement,
|
||||||
|
slowestRafGaps: state.slowFrames
|
||||||
|
.sort((a, b) => b.duration - a.duration)
|
||||||
|
.slice(0, 20)
|
||||||
|
.map((frame) => ({
|
||||||
|
durationMs: frame.duration,
|
||||||
|
index: frame.index,
|
||||||
|
phase: frame.phase,
|
||||||
|
tokenSpans: frame.tokenSpans,
|
||||||
|
blocks: frame.blocks,
|
||||||
|
codeBlocks: frame.codeBlocks,
|
||||||
|
heightPx: frame.height,
|
||||||
|
distancePx: frame.distance,
|
||||||
|
})),
|
||||||
|
slowRafGapPhases: Object.fromEntries(
|
||||||
|
["stream", "boundary", "complete", "unknown"].map((phase) => {
|
||||||
|
const frames = state.slowFrames.filter((frame) => frame.phase === phase)
|
||||||
|
return [
|
||||||
|
phase,
|
||||||
|
{
|
||||||
|
count: frames.length,
|
||||||
|
totalMs: frames.reduce((sum, frame) => sum + frame.duration, 0),
|
||||||
|
maxMs: Math.max(0, ...frames.map((frame) => frame.duration)),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
scroll: state.scroll,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
const geometry = state.minimal
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
maxDistancePx: Math.max(0, ...state.geometry.map((sample) => sample.distance)),
|
||||||
|
finalDistancePx: state.geometry.at(-1)?.distance ?? 0,
|
||||||
|
final: state.geometry.at(-1),
|
||||||
|
distanceTransitionsPx: state.geometry
|
||||||
|
.map((sample) => Math.round(sample.distance))
|
||||||
|
.filter((value, index, values) => index === 0 || value !== values[index - 1]),
|
||||||
|
bottomDriftTransitions: state.geometry.slice(1).filter((value, index) => {
|
||||||
|
const previous = state.geometry[index]?.distance ?? 0
|
||||||
|
return previous <= 1 && value.distance > 1
|
||||||
|
}).length,
|
||||||
|
blankSamples: state.blanks,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
capabilities: { visual: state.profileVisual, geometry: !state.minimal },
|
||||||
|
completionObservedMs,
|
||||||
|
deltasPerSecond: Number.isFinite(completionObservedMs) ? finalIndex / (completionObservedMs / 1_000) : null,
|
||||||
|
rafGapSamples: state.frames.length,
|
||||||
|
rafCallbackRate: duration ? (state.frames.length * 1000) / duration : 0,
|
||||||
|
observedProgressWindowRafCallbackRate: busyDuration ? (busyFrames.length * 1000) / busyDuration : null,
|
||||||
|
observedProgressWindowRafGapP95Ms: busySorted[Math.floor(busySorted.length * 0.95)] ?? null,
|
||||||
|
observedProgressWindowRafGaps: busyFrames.length,
|
||||||
|
maxObservedProgressIndex: Math.max(-1, ...state.applied.map((value) => value.index)),
|
||||||
|
observedProgressTransitions: state.applied.length,
|
||||||
|
rafGapP50Ms: sorted[Math.floor(sorted.length * 0.5)] ?? 0,
|
||||||
|
rafGapP95Ms: sorted[Math.floor(sorted.length * 0.95)] ?? 0,
|
||||||
|
rafGapP99Ms: sorted[Math.floor(sorted.length * 0.99)] ?? 0,
|
||||||
|
maxRafGapMs: sorted.at(-1) ?? 0,
|
||||||
|
rafGapsOver33Ms: state.frames.filter((value) => value > 33.34).length,
|
||||||
|
rafGapsOver50Ms: state.frames.filter((value) => value > 50).length,
|
||||||
|
missedFrameBudgetEquivalents: state.frames.reduce(
|
||||||
|
(sum, value) => sum + Math.max(0, Math.round(value / 16.67) - 1),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
longestRafGapOver33MsStreak: longestSlowStreak,
|
||||||
|
longTaskCount: state.longTasks.length,
|
||||||
|
longTaskTimeMs: state.longTasks.reduce((sum, value) => sum + value, 0),
|
||||||
|
visual,
|
||||||
|
geometry,
|
||||||
|
rowReplaced: row !== state.row,
|
||||||
|
markdownReplaced: markdown !== state.markdown,
|
||||||
|
domTextCharacters: part?.textContent?.length ?? 0,
|
||||||
|
}
|
||||||
|
}, options)
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
const words = [
|
||||||
|
"alpha",
|
||||||
|
"bravo",
|
||||||
|
"charlie",
|
||||||
|
"delta",
|
||||||
|
"echo",
|
||||||
|
"foxtrot",
|
||||||
|
"golf",
|
||||||
|
"hotel",
|
||||||
|
"india",
|
||||||
|
"juliet",
|
||||||
|
"kilo",
|
||||||
|
"lima",
|
||||||
|
"metro",
|
||||||
|
"nova",
|
||||||
|
"orbit",
|
||||||
|
"pixel",
|
||||||
|
"quartz",
|
||||||
|
"river",
|
||||||
|
"signal",
|
||||||
|
"vector",
|
||||||
|
]
|
||||||
|
|
||||||
|
const sourceID = "ses_smoke_source"
|
||||||
|
const targetID = "ses_smoke_target"
|
||||||
|
const childID = "ses_smoke_child"
|
||||||
|
const directory = "C:/OpenCode/SmokeProject"
|
||||||
|
const projectID = "proj_smoke_timeline"
|
||||||
|
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||||
|
|
||||||
|
type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
||||||
|
type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
|
||||||
|
type Message = { info: MessageInfo; parts: MessagePart[] }
|
||||||
|
|
||||||
|
function lorem(seed: number, length: number) {
|
||||||
|
let out = ""
|
||||||
|
let i = seed
|
||||||
|
while (out.length < length) {
|
||||||
|
const word = words[i % words.length]
|
||||||
|
out += (out ? " " : "") + word
|
||||||
|
if (i % 17 === 0) out += ".\n\n"
|
||||||
|
i += 7
|
||||||
|
}
|
||||||
|
return out.slice(0, length)
|
||||||
|
}
|
||||||
|
|
||||||
|
function id(prefix: string, value: number) {
|
||||||
|
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
|
||||||
|
const messageID = id("msg_user", index)
|
||||||
|
return {
|
||||||
|
info: {
|
||||||
|
id: messageID,
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1700000000000 + index * 10_000 },
|
||||||
|
summary: { diffs },
|
||||||
|
agent: "build",
|
||||||
|
model,
|
||||||
|
},
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
id: id("prt_user_text", index),
|
||||||
|
sessionID,
|
||||||
|
messageID,
|
||||||
|
type: "text",
|
||||||
|
text: lorem(index, textLength),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
|
||||||
|
const messageID = id("msg_assistant", index)
|
||||||
|
return {
|
||||||
|
info: {
|
||||||
|
id: messageID,
|
||||||
|
sessionID,
|
||||||
|
role: "assistant",
|
||||||
|
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
|
||||||
|
parentID,
|
||||||
|
modelID: model.modelID,
|
||||||
|
providerID: model.providerID,
|
||||||
|
mode: "build",
|
||||||
|
agent: "build",
|
||||||
|
path: { cwd: directory, root: directory },
|
||||||
|
cost: 0.01,
|
||||||
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
variant: "max",
|
||||||
|
finish: "stop",
|
||||||
|
},
|
||||||
|
parts: parts.map((part) => ({
|
||||||
|
...part,
|
||||||
|
sessionID,
|
||||||
|
messageID,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function textPart(index: number, partIndex: number, length: number): MessagePart {
|
||||||
|
const prose = lorem(index * 13 + partIndex, length)
|
||||||
|
const text =
|
||||||
|
index % 12 === 0
|
||||||
|
? `${prose}\n\n\`\`\`ts\n${code(index, 80)}\n\`\`\``
|
||||||
|
: index % 5 === 0
|
||||||
|
? `${prose}\n\n\`\`\`ts\nexport const value = "${lorem(index, 220)}"\n\`\`\``
|
||||||
|
: index % 7 === 0
|
||||||
|
? `${prose}\n\nThe wrapped inline value is \`${lorem(index, 180)}\`.`
|
||||||
|
: prose
|
||||||
|
return { id: id(`prt_text_${partIndex}`, index), type: "text", text }
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasoningPart(index: number, partIndex: number, length: number): MessagePart {
|
||||||
|
return {
|
||||||
|
id: id(`prt_reasoning_${partIndex}`, index),
|
||||||
|
type: "reasoning",
|
||||||
|
text: lorem(index * 19 + partIndex, length),
|
||||||
|
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 500 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolPart(
|
||||||
|
index: number,
|
||||||
|
partIndex: number,
|
||||||
|
tool: string,
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
outputLength = 160,
|
||||||
|
metadataOverride?: Record<string, unknown>,
|
||||||
|
): MessagePart {
|
||||||
|
const metadata =
|
||||||
|
metadataOverride ??
|
||||||
|
(tool === "apply_patch"
|
||||||
|
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
||||||
|
: tool === "edit" || tool === "write"
|
||||||
|
? {
|
||||||
|
filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
|
||||||
|
diff: patch(index, outputLength),
|
||||||
|
preview: patch(index + 1, 420),
|
||||||
|
}
|
||||||
|
: tool === "question"
|
||||||
|
? { answers: [["Proceed"], ["Keep sample output"]] }
|
||||||
|
: {})
|
||||||
|
return {
|
||||||
|
id: id(`prt_tool_${tool}_${partIndex}`, index),
|
||||||
|
type: "tool",
|
||||||
|
callID: id("call", index * 10 + partIndex),
|
||||||
|
tool,
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input,
|
||||||
|
output: lorem(index * 23 + partIndex, outputLength),
|
||||||
|
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
|
||||||
|
metadata,
|
||||||
|
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchFile(seed: number, type: "add" | "update" | "delete") {
|
||||||
|
return {
|
||||||
|
filePath: `src/generated/patch-${seed}.ts`,
|
||||||
|
relativePath: `src/generated/patch-${seed}.ts`,
|
||||||
|
type,
|
||||||
|
additions: (seed % 7) + 1,
|
||||||
|
deletions: type === "add" ? 0 : seed % 4,
|
||||||
|
patch: patch(seed, 520),
|
||||||
|
before: type === "add" ? undefined : code(seed, 18),
|
||||||
|
after: type === "delete" ? undefined : code(seed + 1, 24),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileDiff(file: string, seed: number) {
|
||||||
|
const lines = seed % 12 === 0 ? 300 : seed % 8 === 0 ? 2 : 38
|
||||||
|
const before = code(seed, lines, seed % 10 === 0 ? 280 : 32)
|
||||||
|
const after =
|
||||||
|
lines === 2
|
||||||
|
? before.replace("value1", "updatedValue1")
|
||||||
|
: lines === 300
|
||||||
|
? code(seed + 1, lines, seed % 10 === 0 ? 280 : 32)
|
||||||
|
: before.replace("value4", "updatedValue4").replace("value20", "updatedValue20")
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
additions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
|
||||||
|
deletions: lines === 300 ? 300 : lines === 2 ? 1 : 2,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function patch(seed: number, length: number) {
|
||||||
|
return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function code(seed: number, lines: number, width = 32) {
|
||||||
|
return Array.from(
|
||||||
|
{ length: lines },
|
||||||
|
(_, index) => `export const value${index} = "${lorem(seed + index, width)}"`,
|
||||||
|
).join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function turn(index: number): Message[] {
|
||||||
|
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
|
||||||
|
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
|
||||||
|
const parts = [
|
||||||
|
...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
|
||||||
|
...(index % 3 === 0
|
||||||
|
? [
|
||||||
|
toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
|
||||||
|
toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
|
||||||
|
toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
|
||||||
|
toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
textPart(index, 2, 160 + (index % 6) * 90),
|
||||||
|
...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
|
||||||
|
...(index % 6 === 0
|
||||||
|
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||||
|
: []),
|
||||||
|
...(index % 8 === 0
|
||||||
|
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||||
|
: []),
|
||||||
|
...(index % 7 === 0
|
||||||
|
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
|
||||||
|
: []),
|
||||||
|
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
|
||||||
|
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
|
||||||
|
...(index % 13 === 0
|
||||||
|
? [
|
||||||
|
toolPart(
|
||||||
|
index,
|
||||||
|
11,
|
||||||
|
"question",
|
||||||
|
{ questions: [{ question: "Use generated fixture?" }, { question: "Keep same row shape?" }] },
|
||||||
|
120,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(index % 17 === 0
|
||||||
|
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
return [user, assistantMessage(targetID, index, user.info.id, parts)]
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
|
||||||
|
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
||||||
|
userMessage(sourceID, index + 1000, 120),
|
||||||
|
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [
|
||||||
|
textPart(index + 1000, 0, 240),
|
||||||
|
...(index === 11
|
||||||
|
? [
|
||||||
|
toolPart(
|
||||||
|
index + 1000,
|
||||||
|
1,
|
||||||
|
"task",
|
||||||
|
{ description: "Inspect child navigation", subagent_type: "explore" },
|
||||||
|
160,
|
||||||
|
{ sessionId: childID },
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]),
|
||||||
|
]).flat()
|
||||||
|
const childMessages = Array.from({ length: 4 }, (_, index) => [
|
||||||
|
userMessage(childID, index + 2000, 120),
|
||||||
|
assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]),
|
||||||
|
]).flat()
|
||||||
|
|
||||||
|
function renderable(part: MessagePart) {
|
||||||
|
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||||
|
if (part.type === "text") return !!part.text.trim()
|
||||||
|
if (part.type === "reasoning") return !!part.text.trim()
|
||||||
|
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderedParts(message: Message) {
|
||||||
|
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fixture = {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "smoke-project",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||||
|
},
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: sourceID,
|
||||||
|
slug: "source",
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title: "Uncommitted changes inquiry",
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: targetID,
|
||||||
|
slug: "target",
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title: "Example Game: sample jump movement & sample physics analysis",
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000001000, updated: 1700000001000 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: childID,
|
||||||
|
parentID: sourceID,
|
||||||
|
slug: "child",
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title: "Inspect child navigation",
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000002000, updated: 1700000002000 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sourceID,
|
||||||
|
targetID,
|
||||||
|
childID,
|
||||||
|
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages, [childID]: childMessages },
|
||||||
|
expected: {
|
||||||
|
sourceTitle: "Uncommitted changes inquiry",
|
||||||
|
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
||||||
|
childTitle: "Inspect child navigation",
|
||||||
|
sourceMessageIDs: sourceMessages
|
||||||
|
.filter((message) => message.info.role === "user")
|
||||||
|
.map((message) => message.info.id),
|
||||||
|
targetMessageIDs: targetMessages
|
||||||
|
.filter((message) => message.info.role === "user")
|
||||||
|
.map((message) => message.info.id),
|
||||||
|
childMessageIDs: childMessages.filter((message) => message.info.role === "user").map((message) => message.info.id),
|
||||||
|
targetPartIDs: targetMessages.flatMap((message) =>
|
||||||
|
orderedParts(message)
|
||||||
|
.filter(renderable)
|
||||||
|
.map((part) => part.id),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageMessages(sessionID: string, limit: number, before?: string) {
|
||||||
|
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
|
||||||
|
const end = before
|
||||||
|
? Math.max(
|
||||||
|
0,
|
||||||
|
messages.findIndex((message) => message.info.id === before),
|
||||||
|
)
|
||||||
|
: messages.length
|
||||||
|
const start = Math.max(0, end - limit)
|
||||||
|
return {
|
||||||
|
items: messages.slice(start, end),
|
||||||
|
cursor: start > 0 ? messages[start]!.info.id : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||||
|
import { fixture, pageMessages } from "./session-timeline-stress.fixture"
|
||||||
|
|
||||||
|
export async function installTimelineSettings(page: Page) {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"settings.v3",
|
||||||
|
JSON.stringify({
|
||||||
|
general: {
|
||||||
|
newLayoutDesigns: true,
|
||||||
|
editToolPartsExpanded: true,
|
||||||
|
shellToolPartsExpanded: true,
|
||||||
|
showReasoningSummaries: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockStressTimeline(
|
||||||
|
page: Page,
|
||||||
|
input?: { onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void },
|
||||||
|
) {
|
||||||
|
return mockOpenCodeServer(page, {
|
||||||
|
sessions: fixture.sessions,
|
||||||
|
provider: fixture.provider,
|
||||||
|
directory: fixture.directory,
|
||||||
|
project: fixture.project,
|
||||||
|
pageMessages,
|
||||||
|
onMessages: input?.onMessages,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installStressSessionTabs(page: Page, input?: { draftID?: string; sessionIDs?: string[] }) {
|
||||||
|
const server = stressServer()
|
||||||
|
await page.addInitScript(
|
||||||
|
({ directory, sessionIDs, dirBase64, server, draftID }) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:server",
|
||||||
|
JSON.stringify({
|
||||||
|
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||||
|
lastProject: { local: directory },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:tabs",
|
||||||
|
JSON.stringify([
|
||||||
|
...sessionIDs.map((sessionId) => ({
|
||||||
|
type: "session",
|
||||||
|
server,
|
||||||
|
dirBase64,
|
||||||
|
sessionId,
|
||||||
|
})),
|
||||||
|
...(draftID ? [{ type: "draft", draftID, server, directory }] : []),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
directory: fixture.directory,
|
||||||
|
sessionIDs: input?.sessionIDs ?? [fixture.sourceID, fixture.targetID],
|
||||||
|
dirBase64: base64Encode(fixture.directory),
|
||||||
|
server,
|
||||||
|
draftID: input?.draftID,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stressSessionHref(sessionID: string) {
|
||||||
|
return `/server/${base64Encode(stressServer())}/session/${sessionID}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stressDraftHref(draftID: string) {
|
||||||
|
return `/new-session?draftId=${encodeURIComponent(draftID)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function stressServer() {
|
||||||
|
return `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
import os from "node:os"
|
||||||
|
import { prepareChromeTrace } from "../chrome-trace"
|
||||||
|
|
||||||
|
test("creates the configured trace directory", async () => {
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), "opencode-trace-"))
|
||||||
|
try {
|
||||||
|
const file = await prepareChromeTrace(path.join(root, "nested", "traces"), "session/tab", false, "test")
|
||||||
|
expect(file).toEndWith("-session-tab-458ed9e3-test.json")
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { summarizeFirstNavigation } from "../timeline/first-navigation-metrics"
|
||||||
|
|
||||||
|
test("reports blank frames before first destination and stable paint", () => {
|
||||||
|
expect(
|
||||||
|
summarizeFirstNavigation([
|
||||||
|
{ observedAtMs: 16, source: true, destination: false, content: true },
|
||||||
|
{ observedAtMs: 32, source: false, destination: false, content: false },
|
||||||
|
{ observedAtMs: 48, source: false, destination: true, content: true },
|
||||||
|
{ observedAtMs: 64, source: false, destination: true, content: true },
|
||||||
|
{ observedAtMs: 80, source: false, destination: true, content: true },
|
||||||
|
]),
|
||||||
|
).toEqual({
|
||||||
|
samples: 5,
|
||||||
|
firstDestinationObservedMs: 48,
|
||||||
|
stableDestinationObservedMs: 80,
|
||||||
|
sourceSamples: 1,
|
||||||
|
blankSamples: 1,
|
||||||
|
unknownSamples: 0,
|
||||||
|
destinationSamples: 3,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not report stability for interrupted destination frames", () => {
|
||||||
|
expect(
|
||||||
|
summarizeFirstNavigation([
|
||||||
|
{ observedAtMs: 16, source: false, destination: true, content: true },
|
||||||
|
{ observedAtMs: 32, source: false, destination: false, content: true },
|
||||||
|
{ observedAtMs: 48, source: false, destination: true, content: true },
|
||||||
|
]).stableDestinationObservedMs,
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { summarizeNavigationMilestones } from "../timeline/navigation-milestones"
|
||||||
|
|
||||||
|
test("reports first and stable paint for each navigation milestone", () => {
|
||||||
|
expect(
|
||||||
|
summarizeNavigationMilestones([
|
||||||
|
{ observedAtMs: 16, milestones: { content: false, tab: false } },
|
||||||
|
{ observedAtMs: 32, milestones: { content: true, tab: false } },
|
||||||
|
{ observedAtMs: 48, milestones: { content: true, tab: true } },
|
||||||
|
{ observedAtMs: 64, milestones: { content: true, tab: true } },
|
||||||
|
{ observedAtMs: 80, milestones: { content: true, tab: true } },
|
||||||
|
]),
|
||||||
|
).toEqual({
|
||||||
|
samples: 5,
|
||||||
|
milestones: {
|
||||||
|
content: { firstObservedMs: 32, stableObservedMs: 64 },
|
||||||
|
tab: { firstObservedMs: 48, stableObservedMs: 80 },
|
||||||
|
},
|
||||||
|
all: { firstObservedMs: 48, stableObservedMs: 80 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reports missing stability when a milestone appears in the final samples", () => {
|
||||||
|
expect(
|
||||||
|
summarizeNavigationMilestones([
|
||||||
|
{ observedAtMs: 16, milestones: { content: false } },
|
||||||
|
{ observedAtMs: 32, milestones: { content: true } },
|
||||||
|
]),
|
||||||
|
).toEqual({
|
||||||
|
samples: 2,
|
||||||
|
milestones: { content: { firstObservedMs: 32, stableObservedMs: null } },
|
||||||
|
all: { firstObservedMs: 32, stableObservedMs: null },
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { compressCachedRepaintTrace, layoutShiftSample } from "../timeline/session-tab-repaint-probe"
|
||||||
|
|
||||||
|
test("compresses repeated repaint states without losing frame samples", () => {
|
||||||
|
const state = {
|
||||||
|
root: 1,
|
||||||
|
scrollTop: 10,
|
||||||
|
scrollHeight: 20,
|
||||||
|
bottomErrorPx: 0,
|
||||||
|
last: true,
|
||||||
|
rows: [{ key: "row", node: 2, top: 0, bottom: 10 }],
|
||||||
|
mounted: 1,
|
||||||
|
center: "content",
|
||||||
|
}
|
||||||
|
const trace = {
|
||||||
|
timeOriginEpochMs: 1_000,
|
||||||
|
startedAtPerformanceMs: 100,
|
||||||
|
samples: [
|
||||||
|
{ observedAtMs: 16, ...state, destination: ["target"], source: [] },
|
||||||
|
{ observedAtMs: 32, ...state, destination: ["target"], source: [] },
|
||||||
|
{ observedAtMs: 48, ...state, scrollTop: 11, destination: ["target"], source: [] },
|
||||||
|
],
|
||||||
|
mutations: [{ observedAtMs: 20, changed: [{ type: "add", node: 2 }] }],
|
||||||
|
shifts: [{ occurredAtMs: 24, value: 0.1 }],
|
||||||
|
windowMs: 1_000,
|
||||||
|
running: false,
|
||||||
|
stop() {},
|
||||||
|
}
|
||||||
|
const compressed = compressCachedRepaintTrace(trace)
|
||||||
|
const samples = compressed.samples.flatMap((group) =>
|
||||||
|
group.observedAtMs.map((observedAtMs) => ({ observedAtMs, ...group.state })),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(samples).toEqual(trace.samples)
|
||||||
|
expect(compressed.mutations).toEqual(trace.mutations)
|
||||||
|
expect(compressed.shifts).toEqual(trace.shifts)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("records layout shifts at occurrence time within the probe window", () => {
|
||||||
|
expect(layoutShiftSample({ startTime: 99, value: 0.1 }, 100)).toBeUndefined()
|
||||||
|
expect(layoutShiftSample({ startTime: 124, value: 0.2 }, 100)).toEqual({ occurredAtMs: 24, value: 0.2 })
|
||||||
|
})
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { classifySessionSwitch } from "../timeline/session-tab-switch-metrics"
|
||||||
|
|
||||||
|
test("counts source and blank samples before the destination is observed", () => {
|
||||||
|
const result = classifySessionSwitch([
|
||||||
|
{ observedAtMs: 16, destination: [], source: ["source"], hasVisibleRows: true, last: false },
|
||||||
|
{ observedAtMs: 32, destination: [], source: [], hasVisibleRows: false, last: false },
|
||||||
|
{ observedAtMs: 48, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
|
||||||
|
{ observedAtMs: 64, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
|
||||||
|
{ observedAtMs: 80, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result.blankSamples).toBe(1)
|
||||||
|
expect(result.sourceSamples).toBe(1)
|
||||||
|
expect(result.unknownSamples).toBe(0)
|
||||||
|
expect(result.firstDestinationObservedMs).toBe(48)
|
||||||
|
expect(result.stableObservedMs).toBe(80)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not classify mixed source and destination content as correct", () => {
|
||||||
|
const result = classifySessionSwitch([
|
||||||
|
{
|
||||||
|
observedAtMs: 16,
|
||||||
|
destination: ["destination"],
|
||||||
|
source: ["source"],
|
||||||
|
hasVisibleRows: true,
|
||||||
|
last: true,
|
||||||
|
bottomErrorPx: 0,
|
||||||
|
},
|
||||||
|
{ observedAtMs: 32, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
|
||||||
|
{ observedAtMs: 48, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
|
||||||
|
{ observedAtMs: 64, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result.firstCorrectObservedMs).toBe(32)
|
||||||
|
expect(result.stableObservedMs).toBe(64)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reports missing correctness without throwing", () => {
|
||||||
|
const result = classifySessionSwitch([
|
||||||
|
{
|
||||||
|
observedAtMs: 16,
|
||||||
|
destination: ["destination"],
|
||||||
|
source: ["source"],
|
||||||
|
hasVisibleRows: true,
|
||||||
|
last: true,
|
||||||
|
bottomErrorPx: 0,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result.firstDestinationObservedMs).toBe(16)
|
||||||
|
expect(result.firstCorrectObservedMs).toBeNull()
|
||||||
|
expect(result.stableObservedMs).toBeNull()
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { streamChunk } from "../timeline/session-timeline-benchmark.fixture"
|
||||||
|
import { streamProgress } from "../timeline/session-timeline-stream-probe"
|
||||||
|
|
||||||
|
test("classifies emitted stream markers using the fixture cycle", () => {
|
||||||
|
expect(streamProgress("before stream-17 after stream-18")).toEqual({ index: 18, phase: "boundary" })
|
||||||
|
expect(streamProgress("before stream-18 after stream-19")).toEqual({ index: 19, phase: "stream" })
|
||||||
|
expect(streamProgress("benchmark-complete stream-36")).toEqual({ index: 36, phase: "complete" })
|
||||||
|
expect(streamProgress("no marker")).toEqual({ index: -1, phase: "unknown" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("emits progress markers at fixture boundaries", () => {
|
||||||
|
expect(streamProgress(streamChunk(18, 160))).toEqual({ index: 18, phase: "boundary" })
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { layoutShiftValue, removeVisibleRow } from "../timeline/session-timeline-stream-probe"
|
||||||
|
|
||||||
|
test("excludes layout shifts before the probe window and recent input", () => {
|
||||||
|
expect(layoutShiftValue({ startTime: 9, value: 0.1 }, 10)).toBeUndefined()
|
||||||
|
expect(layoutShiftValue({ startTime: 10, value: 0.2, hadRecentInput: true }, 10)).toBeUndefined()
|
||||||
|
expect(layoutShiftValue({ startTime: 11, value: 0.3 }, 10)).toBe(0.3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("classifies removed rows from their last painted visibility", () => {
|
||||||
|
const row = {}
|
||||||
|
const visible = new Set([row])
|
||||||
|
|
||||||
|
expect(removeVisibleRow(visible, row)).toBe(true)
|
||||||
|
expect(removeVisibleRow(visible, row)).toBe(false)
|
||||||
|
})
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { fixture } from "../timeline/session-timeline-stress.fixture"
|
||||||
|
import { stressSessionHref } from "../timeline/timeline-test-helpers"
|
||||||
|
|
||||||
|
test("builds stress session links for the benchmark server", () => {
|
||||||
|
expect(stressSessionHref(fixture.sourceID)).toBe(
|
||||||
|
`/server/${base64Encode("http://127.0.0.1:4096")}/session/${fixture.sourceID}`,
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
|
||||||
|
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.global.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}/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.global.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") return sse(route)
|
||||||
|
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||||
|
if (url.pathname === "/session") return json(route, [current])
|
||||||
|
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", "/session/status"].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 === "/vcs") return json(route, { branch: "main", default_branch: "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" })
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
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, {
|
||||||
|
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 === "/vcs/diff")
|
||||||
|
await page.getByRole("tab", { name: "Changes" }).click()
|
||||||
|
expect(await (await diffResponse).json()).toHaveLength(1)
|
||||||
|
|
||||||
|
const review = page.locator('[data-component="session-review"]')
|
||||||
|
await expectAppVisible(review)
|
||||||
|
await review
|
||||||
|
.getByRole("heading", { name: /review\.ts/ })
|
||||||
|
.getByRole("button")
|
||||||
|
.first()
|
||||||
|
.click()
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
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/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)
|
||||||
|
|
||||||
|
await question.getByRole("radio", { name: /Minimal/ }).click()
|
||||||
|
const reply = page.waitForRequest(
|
||||||
|
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/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(`/session/${sessionID}/permissions/permission-request`)
|
||||||
|
expect(request.postDataJSON()).toEqual({ response: "once" })
|
||||||
|
})
|
||||||
|
|
||||||
|
async function mockServer(
|
||||||
|
page: Page,
|
||||||
|
requests: {
|
||||||
|
permissions?: unknown[] | (() => unknown[])
|
||||||
|
questions?: unknown[] | (() => unknown[])
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
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 } }))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -147,7 +147,8 @@ test.describe("regression: session timeline local row state", () => {
|
|||||||
|
|
||||||
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
||||||
await expectAppVisible(wrapper)
|
await expectAppVisible(wrapper)
|
||||||
await expectAppVisible(wrapper.locator('[data-component="file"][data-mode="diff"]').first())
|
const file = wrapper.locator('[data-component="file"][data-mode="diff"]').first()
|
||||||
|
await expectAppVisible(file)
|
||||||
await markDiffProbe(page)
|
await markDiffProbe(page)
|
||||||
|
|
||||||
events.push({
|
events.push({
|
||||||
@@ -159,7 +160,15 @@ test.describe("regression: session timeline local row state", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
|
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
|
||||||
expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" })
|
const siblingProbe = await readDiffProbe(page)
|
||||||
|
expect(siblingProbe).toEqual({
|
||||||
|
fileMarker: "before",
|
||||||
|
frameMarker: "before",
|
||||||
|
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
|
||||||
|
rowMarker: "before",
|
||||||
|
shadowRoots: 0,
|
||||||
|
toolMarker: "before",
|
||||||
|
})
|
||||||
|
|
||||||
await markDiffProbe(page)
|
await markDiffProbe(page)
|
||||||
events.push({
|
events.push({
|
||||||
@@ -173,7 +182,73 @@ test.describe("regression: session timeline local row state", () => {
|
|||||||
await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible(
|
await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible(
|
||||||
{ timeout: 10_000 },
|
{ timeout: 10_000 },
|
||||||
)
|
)
|
||||||
expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" })
|
expect(await readDiffProbe(page)).toEqual({
|
||||||
|
fileMarker: "before",
|
||||||
|
frameMarker: "before",
|
||||||
|
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
|
||||||
|
rowMarker: "before",
|
||||||
|
shadowRoots: 0,
|
||||||
|
toolMarker: "before",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps a sticky edit header aligned with a multi-hunk diff", async ({ page }) => {
|
||||||
|
const events: EventPayload[] = []
|
||||||
|
const lines = Array.from({ length: 1_000 }, (_, index) => `export const value${index} = ${index}\n`).join("")
|
||||||
|
const after = [100, 300, 500, 700, 900].reduce(
|
||||||
|
(result, index) =>
|
||||||
|
result.replace(`export const value${index} = ${index}`, `export const value${index} = compute(${index})`),
|
||||||
|
lines,
|
||||||
|
)
|
||||||
|
const part = {
|
||||||
|
...editPart,
|
||||||
|
state: {
|
||||||
|
...editPart.state,
|
||||||
|
metadata: {
|
||||||
|
...editPart.state.metadata,
|
||||||
|
filediff: {
|
||||||
|
file: "src/regression.ts",
|
||||||
|
additions: 1,
|
||||||
|
deletions: 1,
|
||||||
|
before: lines,
|
||||||
|
after,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await mockServer(page, events, [userMessage, { ...assistantMessage, parts: [part] }])
|
||||||
|
await configurePage(page)
|
||||||
|
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
|
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
||||||
|
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]').first()
|
||||||
|
const diff = wrapper.locator('[data-component="edit-content"]').first()
|
||||||
|
await expectAppVisible(diff)
|
||||||
|
await expect.poll(() => wrapper.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(500)
|
||||||
|
const samples = await wrapper.evaluate(async (element) => {
|
||||||
|
const root = element.closest<HTMLElement>(".scroll-view__viewport")!
|
||||||
|
element.scrollIntoView({ block: "start" })
|
||||||
|
const result = []
|
||||||
|
for (const offset of [0, 120, 240, 360, 480]) {
|
||||||
|
root.scrollBy(0, offset - (result.at(-1)?.offset ?? 0))
|
||||||
|
await new Promise(requestAnimationFrame)
|
||||||
|
const trigger = element.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')!
|
||||||
|
const diff = element.querySelector<HTMLElement>('[data-component="edit-content"]')!
|
||||||
|
result.push({
|
||||||
|
offset,
|
||||||
|
trigger: trigger.getBoundingClientRect().y,
|
||||||
|
diff: diff.getBoundingClientRect().y,
|
||||||
|
bottom: element.getBoundingClientRect().bottom,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(samples[0]!.trigger).toBeLessThan(samples[0]!.diff)
|
||||||
|
expect(samples.every((sample) => Math.abs(sample.trigger - samples[0]!.trigger) <= 1)).toBe(true)
|
||||||
|
expect(samples.every((sample) => sample.trigger < sample.bottom)).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -186,7 +261,6 @@ async function configurePage(page: Page) {
|
|||||||
editToolPartsExpanded: true,
|
editToolPartsExpanded: true,
|
||||||
shellToolPartsExpanded: true,
|
shellToolPartsExpanded: true,
|
||||||
showReasoningSummaries: true,
|
showReasoningSummaries: true,
|
||||||
showSessionProgressBar: true,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -247,10 +321,16 @@ async function markDiffProbe(page: Page) {
|
|||||||
.evaluate((element) => {
|
.evaluate((element) => {
|
||||||
const tool = element as HTMLElement
|
const tool = element as HTMLElement
|
||||||
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
|
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
|
||||||
|
const row = tool.closest<HTMLElement>("[data-timeline-key]")
|
||||||
|
const frame = tool.closest<HTMLElement>("[data-timeline-row]")
|
||||||
if (!file) throw new Error("missing edit diff file")
|
if (!file) throw new Error("missing edit diff file")
|
||||||
|
if (!row) throw new Error("missing virtual timeline row")
|
||||||
|
if (!frame) throw new Error("missing timeline row frame")
|
||||||
|
|
||||||
tool.dataset.timelineProbe = "before"
|
tool.dataset.timelineProbe = "before"
|
||||||
file.dataset.timelineProbe = "before"
|
file.dataset.timelineProbe = "before"
|
||||||
|
row.dataset.timelineProbe = "before"
|
||||||
|
frame.dataset.timelineProbe = "before"
|
||||||
window.__timelineDiffProbe.reset()
|
window.__timelineDiffProbe.reset()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -262,10 +342,15 @@ async function readDiffProbe(page: Page) {
|
|||||||
.evaluate((element) => {
|
.evaluate((element) => {
|
||||||
const tool = element as HTMLElement
|
const tool = element as HTMLElement
|
||||||
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
|
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
|
||||||
|
const row = tool.closest<HTMLElement>("[data-timeline-key]")
|
||||||
|
const frame = tool.closest<HTMLElement>("[data-timeline-row]")
|
||||||
return {
|
return {
|
||||||
fileMarker: file?.dataset.timelineProbe,
|
fileMarker: file?.dataset.timelineProbe,
|
||||||
shadowRoots: window.__timelineDiffProbe.shadowRoots(),
|
shadowRoots: window.__timelineDiffProbe.shadowRoots(),
|
||||||
toolMarker: tool.dataset.timelineProbe,
|
toolMarker: tool.dataset.timelineProbe,
|
||||||
|
rowMarker: row?.dataset.timelineProbe,
|
||||||
|
rowKey: row?.dataset.timelineKey,
|
||||||
|
frameMarker: frame?.dataset.timelineProbe,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -300,14 +385,15 @@ function readExpanded(element: Element) {
|
|||||||
return !!content && content.getBoundingClientRect().height > 0
|
return !!content && content.getBoundingClientRect().height > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mockServer(page: Page, events: EventPayload[]) {
|
async function mockServer(page: Page, events: EventPayload[], messages = [userMessage, assistantMessage]) {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
directory,
|
directory,
|
||||||
project: project(),
|
project: project(),
|
||||||
provider: provider(),
|
provider: provider(),
|
||||||
sessions: [session()],
|
sessions: [session()],
|
||||||
pageMessages: () => ({ items: [userMessage, assistantMessage] }),
|
pageMessages: () => ({ items: messages }),
|
||||||
events: () => events.splice(0),
|
events: () => events.splice(0, 1),
|
||||||
|
eventRetry: 16,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ test.describe("regression: session timeline context group resize", () => {
|
|||||||
const samples = await sampleExpansion(page)
|
const samples = await sampleExpansion(page)
|
||||||
const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5)
|
const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5)
|
||||||
|
|
||||||
console.log("context resize samples", JSON.stringify(samples, null, 2))
|
|
||||||
|
|
||||||
expect(samples[0]?.overlap).toBe(0)
|
expect(samples[0]?.overlap).toBe(0)
|
||||||
expect(visibleOverlap).toEqual([])
|
expect(visibleOverlap).toEqual([])
|
||||||
expect(samples.at(-1)?.expanded).toBe("true")
|
expect(samples.at(-1)?.expanded).toBe("true")
|
||||||
@@ -49,7 +47,6 @@ async function configurePage(page: Page) {
|
|||||||
editToolPartsExpanded: true,
|
editToolPartsExpanded: true,
|
||||||
shellToolPartsExpanded: true,
|
shellToolPartsExpanded: true,
|
||||||
showReasoningSummaries: true,
|
showReasoningSummaries: true,
|
||||||
showSessionProgressBar: true,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -115,13 +112,15 @@ async function sampleExpansion(page: Page) {
|
|||||||
|
|
||||||
let frame = 1
|
let frame = 1
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
capture(frame, "raf")
|
setTimeout(() => {
|
||||||
frame += 1
|
capture(frame, "painted")
|
||||||
if (frame > 8) {
|
frame += 1
|
||||||
resolve(samples)
|
if (frame > 8) {
|
||||||
return
|
resolve(samples)
|
||||||
}
|
return
|
||||||
requestAnimationFrame(tick)
|
}
|
||||||
|
requestAnimationFrame(tick)
|
||||||
|
}, 0)
|
||||||
}
|
}
|
||||||
requestAnimationFrame(tick)
|
requestAnimationFrame(tick)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
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<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
|
||||||
|
|
||||||
|
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)],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
events: () => events.splice(0, 1),
|
||||||
|
eventRetry: 16,
|
||||||
|
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)
|
||||||
|
|
||||||
|
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.global.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<HTMLElement>('[data-component="session-todo-dock"]')
|
||||||
|
const clip = dock?.parentElement?.parentElement
|
||||||
|
const label = dock?.querySelector<HTMLElement>('[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)
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ const words = [
|
|||||||
"vector",
|
"vector",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const serverKey = "http://127.0.0.1:4096"
|
||||||
const sourceID = "ses_smoke_source"
|
const sourceID = "ses_smoke_source"
|
||||||
const targetID = "ses_smoke_target"
|
const targetID = "ses_smoke_target"
|
||||||
const directory = "C:/OpenCode/SmokeProject"
|
const directory = "C:/OpenCode/SmokeProject"
|
||||||
@@ -139,7 +140,7 @@ function toolPart(
|
|||||||
status: "completed",
|
status: "completed",
|
||||||
input,
|
input,
|
||||||
output: lorem(index * 23 + partIndex, outputLength),
|
output: lorem(index * 23 + partIndex, outputLength),
|
||||||
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
|
title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed",
|
||||||
metadata,
|
metadata,
|
||||||
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
|
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
|
||||||
},
|
},
|
||||||
@@ -200,9 +201,7 @@ function turn(index: number): Message[] {
|
|||||||
...(index % 8 === 0
|
...(index % 8 === 0
|
||||||
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||||
: []),
|
: []),
|
||||||
...(index % 7 === 0
|
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
|
||||||
? [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 % 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 % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
|
||||||
...(index % 13 === 0
|
...(index % 13 === 0
|
||||||
@@ -242,6 +241,7 @@ function orderedParts(message: Message) {
|
|||||||
|
|
||||||
export const fixture = {
|
export const fixture = {
|
||||||
directory,
|
directory,
|
||||||
|
serverKey,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
worktree: directory,
|
worktree: directory,
|
||||||
@@ -295,6 +295,7 @@ export const fixture = {
|
|||||||
.filter(renderable)
|
.filter(renderable)
|
||||||
.map((part) => part.id),
|
.map((part) => part.id),
|
||||||
),
|
),
|
||||||
|
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,295 @@ type SmokeWindow = Window & {
|
|||||||
test.describe("smoke: session timeline", () => {
|
test.describe("smoke: session timeline", () => {
|
||||||
test.setTimeout(240_000)
|
test.setTimeout(240_000)
|
||||||
|
|
||||||
|
test("keeps the visible message fixed while prepending history", async ({ page }) => {
|
||||||
|
const requests: { before?: string; phase: "start" | "end"; at: number }[] = []
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
sessions: fixture.sessions,
|
||||||
|
provider: fixture.provider,
|
||||||
|
directory: fixture.directory,
|
||||||
|
project: fixture.project,
|
||||||
|
pageMessages,
|
||||||
|
messageDelay: 3_000,
|
||||||
|
onMessages: (input) => requests.push({ before: input.before, phase: input.phase, at: performance.now() }),
|
||||||
|
})
|
||||||
|
await configureSmokePage(page, fixture.directory)
|
||||||
|
|
||||||
|
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
|
||||||
|
await waitForTimelineStable(page)
|
||||||
|
const scroller = timelineScroller(page)
|
||||||
|
await pointAtTimeline(page)
|
||||||
|
const deadline = Date.now() + 120_000
|
||||||
|
while (!requests.some((request) => request.before && request.phase === "start")) {
|
||||||
|
if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary")
|
||||||
|
await page.mouse.wheel(0, -240)
|
||||||
|
await page.waitForTimeout(20)
|
||||||
|
}
|
||||||
|
expect(requests.some((request) => request.before && request.phase === "end")).toBe(false)
|
||||||
|
for (let index = 0; index < 12; index++) {
|
||||||
|
await page.mouse.wheel(0, -120)
|
||||||
|
await page.waitForTimeout(20)
|
||||||
|
}
|
||||||
|
const keys = await scroller.evaluate((element) => {
|
||||||
|
const view = element.getBoundingClientRect()
|
||||||
|
return [...element.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
|
||||||
|
.filter((row) => {
|
||||||
|
const rect = row.getBoundingClientRect()
|
||||||
|
return rect.bottom > view.top && rect.top < view.bottom
|
||||||
|
})
|
||||||
|
.map((row) => row.dataset.timelinePartId)
|
||||||
|
.filter((id): id is string => !!id)
|
||||||
|
.slice(0, 3)
|
||||||
|
})
|
||||||
|
expect(keys.length).toBeGreaterThan(0)
|
||||||
|
const positions = () =>
|
||||||
|
scroller.evaluate((element, keys) => {
|
||||||
|
const top = element.getBoundingClientRect().top
|
||||||
|
return Object.fromEntries(
|
||||||
|
keys.map((key) => {
|
||||||
|
const row = element.querySelector<HTMLElement>(`[data-timeline-part-id="${key}"]`)
|
||||||
|
if (!row) throw new Error(`Missing stable timeline key: ${key}`)
|
||||||
|
return [key, Math.round((row.getBoundingClientRect().top - top) * devicePixelRatio) / devicePixelRatio]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, keys)
|
||||||
|
const before = await positions()
|
||||||
|
expect(requests.some((request) => request.before && request.phase === "end")).toBe(false)
|
||||||
|
|
||||||
|
await expect.poll(() => requests.some((request) => request.before && request.phase === "end")).toBe(true)
|
||||||
|
await waitForTimelineStable(page)
|
||||||
|
await expect.poll(positions).toEqual(before)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves the timeline gap above the composer", async ({ page }) => {
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
sessions: fixture.sessions,
|
||||||
|
provider: fixture.provider,
|
||||||
|
directory: fixture.directory,
|
||||||
|
project: fixture.project,
|
||||||
|
pageMessages,
|
||||||
|
})
|
||||||
|
await configureSmokePage(page, fixture.directory)
|
||||||
|
|
||||||
|
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
|
||||||
|
await waitForTimelineStable(page)
|
||||||
|
const scroller = timelineScroller(page)
|
||||||
|
await scroller.evaluate((element) => {
|
||||||
|
element.scrollTop = element.scrollHeight
|
||||||
|
})
|
||||||
|
await waitForTimelineStable(page)
|
||||||
|
|
||||||
|
const spacer = scroller.locator('[data-timeline-row="bottom-spacer"]')
|
||||||
|
await expect(spacer).toBeVisible()
|
||||||
|
expect(await spacer.evaluate((element) => element.getBoundingClientRect().height)).toBe(64)
|
||||||
|
await expect
|
||||||
|
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||||
|
.toBeLessThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("paints cached session tabs at the latest message", async ({ page }) => {
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
sessions: fixture.sessions,
|
||||||
|
provider: fixture.provider,
|
||||||
|
directory: fixture.directory,
|
||||||
|
project: fixture.project,
|
||||||
|
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
|
||||||
|
})
|
||||||
|
await configureSmokePage(page, fixture.directory)
|
||||||
|
await page.addInitScript(
|
||||||
|
({ dirBase64, sourceID, targetID }) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:tabs",
|
||||||
|
JSON.stringify(
|
||||||
|
[sourceID, targetID].map((sessionId) => ({
|
||||||
|
type: "session",
|
||||||
|
server: "http://127.0.0.1:4096",
|
||||||
|
dirBase64,
|
||||||
|
sessionId,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`)
|
||||||
|
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||||
|
await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
||||||
|
|
||||||
|
const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
|
||||||
|
const last = fixture.expected.targetMessageIDs.at(-1)!
|
||||||
|
await page.evaluate(
|
||||||
|
({ destination, last }) => {
|
||||||
|
const ids = new Set(destination)
|
||||||
|
const samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> = []
|
||||||
|
const firstPaintNodes = new WeakSet<Node>()
|
||||||
|
let firstPaint = false
|
||||||
|
let removedFirstPaintNodes = 0
|
||||||
|
let running = true
|
||||||
|
new MutationObserver((records) => {
|
||||||
|
if (!firstPaint || !running) return
|
||||||
|
records.forEach((record) =>
|
||||||
|
record.removedNodes.forEach((node) => {
|
||||||
|
if (firstPaintNodes.has(node)) removedFirstPaintNodes += 1
|
||||||
|
if (!(node instanceof Element)) return
|
||||||
|
node.querySelectorAll("*").forEach((element) => {
|
||||||
|
if (firstPaintNodes.has(element)) removedFirstPaintNodes += 1
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}).observe(document.documentElement, { childList: true, subtree: true })
|
||||||
|
const sample = () => {
|
||||||
|
if (!running) return
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!running) return
|
||||||
|
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||||
|
element.querySelector("[data-timeline-row]"),
|
||||||
|
)
|
||||||
|
if (root) {
|
||||||
|
const view = root.getBoundingClientRect()
|
||||||
|
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
|
||||||
|
.filter((element) => {
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
return rect.bottom > view.top && rect.top < view.bottom
|
||||||
|
})
|
||||||
|
.map((element) => element.dataset.messageId!)
|
||||||
|
.filter((id) => ids.has(id))
|
||||||
|
const bottom = root
|
||||||
|
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
|
||||||
|
?.getBoundingClientRect()
|
||||||
|
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
|
||||||
|
if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) {
|
||||||
|
firstPaint = true
|
||||||
|
root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => {
|
||||||
|
const rect = row.getBoundingClientRect()
|
||||||
|
if (rect.bottom <= view.top || rect.top >= view.bottom) return
|
||||||
|
firstPaintNodes.add(row)
|
||||||
|
row.querySelectorAll("*").forEach((element) => firstPaintNodes.add(element))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestAnimationFrame(sample)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
;(
|
||||||
|
window as Window & {
|
||||||
|
__sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void }
|
||||||
|
}
|
||||||
|
).__sessionTabPaint = {
|
||||||
|
samples,
|
||||||
|
removed: () => removedFirstPaintNodes,
|
||||||
|
stop: () => {
|
||||||
|
running = false
|
||||||
|
},
|
||||||
|
}
|
||||||
|
requestAnimationFrame(sample)
|
||||||
|
},
|
||||||
|
{ destination, last },
|
||||||
|
)
|
||||||
|
|
||||||
|
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
(
|
||||||
|
window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } }
|
||||||
|
).__sessionTabPaint?.samples.some((sample) => sample.ids.length > 0),
|
||||||
|
)
|
||||||
|
await page.waitForTimeout(200)
|
||||||
|
const first = await page.evaluate(() => {
|
||||||
|
const probe = (
|
||||||
|
window as Window & {
|
||||||
|
__sessionTabPaint?: {
|
||||||
|
samples: Array<{ ids: string[]; last: boolean; bottomError?: number }>
|
||||||
|
removed: () => number
|
||||||
|
stop: () => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).__sessionTabPaint!
|
||||||
|
probe.stop()
|
||||||
|
return { first: probe.samples.find((sample) => sample.ids.length > 0), removed: probe.removed() }
|
||||||
|
})
|
||||||
|
expect(first.first?.last).toBe(true)
|
||||||
|
expect(Math.abs(first.first?.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
|
||||||
|
expect(first.removed).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("paints a cold session tab at the latest message", async ({ page }) => {
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
sessions: fixture.sessions,
|
||||||
|
provider: fixture.provider,
|
||||||
|
directory: fixture.directory,
|
||||||
|
project: fixture.project,
|
||||||
|
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
|
||||||
|
})
|
||||||
|
await configureSmokePage(page, fixture.directory)
|
||||||
|
await page.addInitScript(
|
||||||
|
({ dirBase64, sourceID, targetID }) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:tabs",
|
||||||
|
JSON.stringify(
|
||||||
|
[sourceID, targetID].map((sessionId) => ({
|
||||||
|
type: "session",
|
||||||
|
server: "http://127.0.0.1:4096",
|
||||||
|
dirBase64,
|
||||||
|
sessionId,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
|
||||||
|
)
|
||||||
|
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`)
|
||||||
|
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||||
|
const last = fixture.expected.targetMessageIDs.at(-1)!
|
||||||
|
const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
|
||||||
|
await page.evaluate(
|
||||||
|
({ destination, last }) => {
|
||||||
|
const ids = new Set(destination)
|
||||||
|
const samples: Array<{ destination: boolean; last: boolean; bottomError?: number }> = []
|
||||||
|
const sample = () => {
|
||||||
|
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||||
|
element.querySelector("[data-timeline-row]"),
|
||||||
|
)
|
||||||
|
if (root) {
|
||||||
|
const view = root.getBoundingClientRect()
|
||||||
|
const spacer = root
|
||||||
|
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
|
||||||
|
?.getBoundingClientRect()
|
||||||
|
const messages = [...root.querySelectorAll<HTMLElement>("[data-message-id]")].filter((element) => {
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
return rect.bottom > view.top && rect.top < view.bottom
|
||||||
|
})
|
||||||
|
samples.push({
|
||||||
|
destination: messages.some((element) => ids.has(element.dataset.messageId!)),
|
||||||
|
last: messages.some((element) => element.dataset.messageId === last),
|
||||||
|
bottomError: spacer ? spacer.bottom - view.bottom : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => setTimeout(sample, 0))
|
||||||
|
}
|
||||||
|
;(window as Window & { __coldTabSamples?: typeof samples }).__coldTabSamples = samples
|
||||||
|
requestAnimationFrame(() => setTimeout(sample, 0))
|
||||||
|
},
|
||||||
|
{ destination, last },
|
||||||
|
)
|
||||||
|
|
||||||
|
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
(window as Window & { __coldTabSamples?: Array<{ destination: boolean }> }).__coldTabSamples?.some(
|
||||||
|
(sample) => sample.destination,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const samples = (
|
||||||
|
window as Window & {
|
||||||
|
__coldTabSamples?: Array<{ destination: boolean; last: boolean; bottomError?: number }>
|
||||||
|
}
|
||||||
|
).__coldTabSamples!
|
||||||
|
return samples.find((sample) => sample.destination)!
|
||||||
|
})
|
||||||
|
expect(result.last).toBe(true)
|
||||||
|
expect(Math.abs(result.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
test("renders seeded timeline in order while paging through history", async ({ page }) => {
|
test("renders seeded timeline in order while paging through history", async ({ page }) => {
|
||||||
const errors = trackPageErrors(page)
|
const errors = trackPageErrors(page)
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
@@ -49,6 +338,18 @@ test.describe("smoke: session timeline", () => {
|
|||||||
const expectedMessageIDs = fixture.expected.targetMessageIDs
|
const expectedMessageIDs = fixture.expected.targetMessageIDs
|
||||||
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
|
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
|
||||||
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
|
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
|
||||||
|
|
||||||
|
const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`)
|
||||||
|
const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
|
||||||
|
const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
|
||||||
|
await expect(shellSubtitle).toHaveCount(0)
|
||||||
|
await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck")
|
||||||
|
await shellTrigger.click()
|
||||||
|
await expect(shellTrigger).toHaveAttribute("aria-expanded", "false")
|
||||||
|
await expect(shellSubtitle).toHaveText("bun typecheck")
|
||||||
|
await shellTrigger.click()
|
||||||
|
await expect(shellTrigger).toHaveAttribute("aria-expanded", "true")
|
||||||
|
await expect(shellSubtitle).toHaveCount(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -61,7 +362,6 @@ async function configureSmokePage(page: Page, directory: string) {
|
|||||||
editToolPartsExpanded: true,
|
editToolPartsExpanded: true,
|
||||||
shellToolPartsExpanded: true,
|
shellToolPartsExpanded: true,
|
||||||
showReasoningSummaries: true,
|
showReasoningSummaries: true,
|
||||||
showSessionProgressBar: true,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -427,6 +727,14 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin
|
|||||||
await expectSessionTitle(page, expectedTitle)
|
await expectSessionTitle(page, expectedTitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
|
||||||
|
const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`
|
||||||
|
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
}
|
||||||
|
|
||||||
async function expectSessionReady(page: Page) {
|
async function expectSessionReady(page: Page) {
|
||||||
await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
|
await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
import type { Page, Route } from "@playwright/test"
|
import type { Page, Route } from "@playwright/test"
|
||||||
|
|
||||||
const emptyList = new Set([
|
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
|
||||||
"/skill",
|
|
||||||
"/command",
|
|
||||||
"/lsp",
|
|
||||||
"/formatter",
|
|
||||||
"/permission",
|
|
||||||
"/question",
|
|
||||||
"/vcs/status",
|
|
||||||
"/vcs/diff",
|
|
||||||
])
|
|
||||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"])
|
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"])
|
||||||
|
|
||||||
export interface MockServerConfig {
|
export interface MockServerConfig {
|
||||||
@@ -18,10 +9,19 @@ export interface MockServerConfig {
|
|||||||
project: unknown
|
project: unknown
|
||||||
sessions: ({ id: string } & Record<string, unknown>)[]
|
sessions: ({ id: string } & Record<string, unknown>)[]
|
||||||
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
|
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
|
||||||
|
vcsDiff?: unknown[]
|
||||||
|
messageDelay?: number
|
||||||
|
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
|
||||||
events?: () => unknown[]
|
events?: () => unknown[]
|
||||||
|
eventRetry?: number
|
||||||
|
todos?: (sessionID: string) => unknown[]
|
||||||
|
permissions?: unknown[] | (() => unknown[])
|
||||||
|
questions?: unknown[] | (() => unknown[])
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
|
const cursors = new Map<string, string>()
|
||||||
|
let nextCursor = 0
|
||||||
const staticRoutes: Record<string, unknown> = {
|
const staticRoutes: Record<string, unknown> = {
|
||||||
"/provider": config.provider,
|
"/provider": config.provider,
|
||||||
"/path": {
|
"/path": {
|
||||||
@@ -41,11 +41,19 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
await page.route("**/*", async (route) => {
|
await page.route("**/*", async (route) => {
|
||||||
const url = new URL(route.request().url())
|
const url = new URL(route.request().url())
|
||||||
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||||
if (url.port !== targetPort) return route.fallback()
|
const appPort = new URL(
|
||||||
|
process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
|
||||||
|
).port
|
||||||
|
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
|
||||||
|
|
||||||
const path = url.pathname
|
const path = url.pathname
|
||||||
if (path === "/global/event" || path === "/event") return sse(route, config.events?.())
|
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
||||||
if (path === "/global/health") return json(route, { healthy: true })
|
if (path === "/global/health") return json(route, { healthy: 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 === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
|
||||||
if (emptyObject.has(path)) return json(route, {})
|
if (emptyObject.has(path)) return json(route, {})
|
||||||
if (emptyList.has(path)) return json(route, [])
|
if (emptyList.has(path)) return json(route, [])
|
||||||
if (path in staticRoutes) return json(route, staticRoutes[path])
|
if (path in staticRoutes) return json(route, staticRoutes[path])
|
||||||
@@ -56,23 +64,34 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
return json(route, session ?? {})
|
return json(route, session ?? {})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
|
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 messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||||
if (messagesMatch) {
|
if (messagesMatch) {
|
||||||
|
const token = url.searchParams.get("before") ?? undefined
|
||||||
|
const before = token ? cursors.get(token) : undefined
|
||||||
|
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
||||||
|
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
|
||||||
|
if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||||
const limit = Number(url.searchParams.get("limit") ?? 80)
|
const limit = Number(url.searchParams.get("limit") ?? 80)
|
||||||
const before = url.searchParams.get("before") ?? undefined
|
|
||||||
const pageData = config.pageMessages(messagesMatch[1], limit, before)
|
const pageData = config.pageMessages(messagesMatch[1], limit, before)
|
||||||
return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined)
|
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
|
||||||
|
if (!pageData.cursor) return json(route, pageData.items)
|
||||||
|
const cursor = `cursor_${++nextCursor}`
|
||||||
|
cursors.set(cursor, pageData.cursor)
|
||||||
|
return json(route, pageData.items, { "x-next-cursor": cursor })
|
||||||
}
|
}
|
||||||
|
|
||||||
return json(route, {})
|
if (url.port === targetPort && targetPort !== appPort) return json(route, {})
|
||||||
|
return route.fallback()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function json(route: Route, body: unknown, headers?: Record<string, string>) {
|
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: {
|
headers: {
|
||||||
"access-control-allow-origin": "*",
|
"access-control-allow-origin": "*",
|
||||||
@@ -83,10 +102,10 @@ function json(route: Route, body: unknown, headers?: Record<string, string>) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function sse(route: Route, events?: unknown[]) {
|
function sse(route: Route, events?: unknown[], retry?: number) {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "text/event-stream",
|
contentType: "text/event-stream",
|
||||||
body: events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n",
|
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-5
@@ -1,22 +1,28 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en" style="background-color: var(--background-base)">
|
<html lang="en" style="background-color: var(--v2-background-bg-deep, #fafafa)">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, interactive-widget=resizes-content, viewport-fit=cover"
|
||||||
|
/>
|
||||||
<title>OpenCode</title>
|
<title>OpenCode</title>
|
||||||
<link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
|
<link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />
|
||||||
<link rel="shortcut icon" href="/favicon-v3.ico" />
|
<link rel="shortcut icon" href="/favicon-v3.ico" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v3.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v3.png" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<meta name="theme-color" content="#F8F7F7" />
|
<meta name="theme-color" content="#fafafa" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta property="og:image" content="/social-share.png" />
|
<meta property="og:image" content="/social-share.png" />
|
||||||
<meta property="twitter:image" content="/social-share.png" />
|
<meta property="twitter:image" content="/social-share.png" />
|
||||||
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
<body class="antialiased overscroll-none text-12-regular overflow-hidden bg-v2-background-bg-deep">
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root" class="flex flex-col h-dvh p-px"></div>
|
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
|
||||||
<script src="/src/entry.tsx" type="module"></script>
|
<script src="/src/entry.tsx" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.17.4",
|
"version": "1.17.11",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -17,13 +17,15 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"test": "bun run test:unit",
|
"test": "bun run test:unit && bun run test:browser",
|
||||||
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
|
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
|
||||||
|
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
|
||||||
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:local": "playwright test",
|
"test:e2e:local": "playwright test",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"test:e2e:report": "playwright show-report e2e/playwright-report"
|
"test:e2e:report": "playwright show-report e2e/playwright-report",
|
||||||
|
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -45,7 +47,9 @@
|
|||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
|
"@opencode-ai/session-ui": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
|
"@pierre/trees": "1.0.0-beta.4",
|
||||||
"@sentry/solid": "catalog:",
|
"@sentry/solid": "catalog:",
|
||||||
"@shikijs/transformers": "3.9.2",
|
"@shikijs/transformers": "3.9.2",
|
||||||
"@solid-primitives/active-element": "2.1.3",
|
"@solid-primitives/active-element": "2.1.3",
|
||||||
@@ -63,6 +67,7 @@
|
|||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
"@tanstack/solid-query": "5.91.4",
|
"@tanstack/solid-query": "5.91.4",
|
||||||
|
"@tanstack/solid-virtual": "catalog:",
|
||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -75,7 +80,6 @@
|
|||||||
"shiki": "catalog:",
|
"shiki": "catalog:",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"solid-list": "catalog:",
|
"solid-list": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:"
|
||||||
"virtua": "catalog:"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const reuse = !process.env.CI
|
|||||||
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
|
testIgnore: process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
|
||||||
outputDir: "./e2e/test-results",
|
outputDir: "./e2e/test-results",
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
expect: {
|
expect: {
|
||||||
|
|||||||
@@ -15,10 +15,11 @@
|
|||||||
|
|
||||||
document.documentElement.dataset.theme = themeId
|
document.documentElement.dataset.theme = themeId
|
||||||
document.documentElement.dataset.colorScheme = mode
|
document.documentElement.dataset.colorScheme = mode
|
||||||
|
document.documentElement.style.backgroundColor = isDark ? "#080808" : "#fafafa"
|
||||||
|
|
||||||
// Update theme-color meta tag to match app color scheme
|
// Update theme-color meta tag to match app color scheme
|
||||||
var metas = document.querySelectorAll("meta[name='theme-color']")
|
var metas = document.querySelectorAll("meta[name='theme-color']")
|
||||||
if (metas.length > 0) metas[0].setAttribute("content", isDark ? "#131010" : "#F8F7F7")
|
if (metas.length > 0) metas[0].setAttribute("content", isDark ? "#080808" : "#fafafa")
|
||||||
|
|
||||||
if (themeId === "oc-2") return
|
if (themeId === "oc-2") return
|
||||||
|
|
||||||
|
|||||||
+288
-106
@@ -4,7 +4,7 @@ import { I18nProvider } from "@opencode-ai/ui/context"
|
|||||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||||
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||||
import { File } from "@opencode-ai/ui/file"
|
import { File } from "@opencode-ai/session-ui/file"
|
||||||
import { Font } from "@opencode-ai/ui/font"
|
import { Font } from "@opencode-ai/ui/font"
|
||||||
import { Splash } from "@opencode-ai/ui/logo"
|
import { Splash } from "@opencode-ai/ui/logo"
|
||||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||||
@@ -30,8 +30,8 @@ import { Dynamic } from "solid-js/web"
|
|||||||
import { CommandProvider } from "@/context/command"
|
import { CommandProvider } from "@/context/command"
|
||||||
import { CommentsProvider } from "@/context/comments"
|
import { CommentsProvider } from "@/context/comments"
|
||||||
import { FileProvider } from "@/context/file"
|
import { FileProvider } from "@/context/file"
|
||||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
|
||||||
import { ServerSyncProvider } from "@/context/server-sync"
|
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
|
||||||
import { GlobalProvider } from "@/context/global"
|
import { GlobalProvider } from "@/context/global"
|
||||||
import { HighlightsProvider } from "@/context/highlights"
|
import { HighlightsProvider } from "@/context/highlights"
|
||||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||||
@@ -47,81 +47,199 @@ import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
|||||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||||
import { WslServersProvider } from "@/wsl/context"
|
import { WslServersProvider } from "@/wsl/context"
|
||||||
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
|
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||||
import Layout from "@/pages/layout"
|
import LegacyLayout from "@/pages/layout"
|
||||||
|
import NewLayout from "@/pages/layout-new"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { useCheckServerHealth } from "./utils/server-health"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
|
import {
|
||||||
|
legacySessionHref,
|
||||||
|
legacySessionServer,
|
||||||
|
requireServerKey,
|
||||||
|
selectSessionLineage,
|
||||||
|
sessionHref,
|
||||||
|
} from "./utils/session-route"
|
||||||
|
import { isSessionNotFoundError } from "./utils/server-errors"
|
||||||
|
|
||||||
|
import Session from "@/pages/session"
|
||||||
|
import { NewHome, LegacyHome } from "@/pages/home"
|
||||||
|
|
||||||
const HomeRoute = lazy(() => import("@/pages/home"))
|
|
||||||
const Session = lazy(() => import("@/pages/session"))
|
|
||||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||||
|
|
||||||
const SessionRoute = Object.assign(
|
const SessionRoute = () => {
|
||||||
() => {
|
const settings = useSettings()
|
||||||
const settings = useSettings()
|
const params = useParams()
|
||||||
const params = useParams()
|
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||||
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
const sdk = useSDK()
|
||||||
const sdk = useSDK()
|
const server = useServer()
|
||||||
const server = useServer()
|
const tabs = useTabs()
|
||||||
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 (
|
return (
|
||||||
|
<Show when={tabs.ready()}>
|
||||||
|
{(_) => {
|
||||||
|
const persisted = tabs.store.filter((item) => item.type === "session")
|
||||||
|
return <Navigate href={sessionHref(legacySessionServer(persisted, sessionID, server.key), sessionID)} />
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<SessionProviders>
|
||||||
|
<Session />
|
||||||
|
</SessionProviders>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TargetSessionRoute = () => {
|
||||||
|
const params = useParams<{ serverKey: string; id: string }>()
|
||||||
|
const server = useServer()
|
||||||
|
const conn = createMemo(() => {
|
||||||
|
const key = requireServerKey(params.serverKey)
|
||||||
|
return server.list.find((item) => ServerConnection.key(item) === key)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||||
|
<ServerSDKProvider server={conn}>
|
||||||
|
<ServerSyncProvider server={conn}>
|
||||||
|
<ResolvedTargetSessionRoute />
|
||||||
|
</ServerSyncProvider>
|
||||||
|
</ServerSDKProvider>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResolvedTargetSessionRoute() {
|
||||||
|
const params = useParams<{ serverKey: string; id: string }>()
|
||||||
|
const settings = useSettings()
|
||||||
|
const tabs = useTabs()
|
||||||
|
const sync = useServerSync()
|
||||||
|
const serverKey = createMemo(() => requireServerKey(params.serverKey))
|
||||||
|
const cached = createMemo(() => sync().session.lineage.peek(params.id))
|
||||||
|
const [resolved] = createResource(
|
||||||
|
() => {
|
||||||
|
if (cached()) return
|
||||||
|
return { id: params.id, server: serverKey(), sync: sync() }
|
||||||
|
},
|
||||||
|
({ id, server, sync }) =>
|
||||||
|
sync.session.lineage.resolve(id).catch((error) => {
|
||||||
|
if (isSessionNotFoundError(error, id)) tabs.removeSessionTab({ server, sessionId: id })
|
||||||
|
throw error
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved()))
|
||||||
|
const directory = createMemo(() => current()?.session.directory)
|
||||||
|
const targetDirectory = () => directory()!
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const session = current()
|
||||||
|
if (!session) return
|
||||||
|
tabs.addSessionTab({
|
||||||
|
server: serverKey(),
|
||||||
|
sessionId: session.root.id,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
||||||
|
<Show when={!!current() || resolved.state !== "errored"} fallback={<ErrorPage error={resolved.error} />}>
|
||||||
|
<Show when={directory()}>
|
||||||
|
<Show
|
||||||
|
when={settings.general.newLayoutDesigns()}
|
||||||
|
fallback={<Navigate href={legacySessionHref(directory()!, params.id)} />}
|
||||||
|
>
|
||||||
|
<SDKProvider directory={targetDirectory}>
|
||||||
|
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
|
||||||
|
<TargetSessionPage />
|
||||||
|
</DirectoryDataProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</TargetServerScopedProviders>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TargetSessionPage() {
|
||||||
|
const sdk = useSDK()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
|
return (
|
||||||
|
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
|
||||||
<SessionProviders>
|
<SessionProviders>
|
||||||
<Session />
|
<Session />
|
||||||
</SessionProviders>
|
</SessionProviders>
|
||||||
)
|
</Show>
|
||||||
},
|
)
|
||||||
{ preload: Session.preload },
|
}
|
||||||
)
|
|
||||||
|
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||||
|
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
|
||||||
|
// Notification/Models + the visual Layout) for that server.
|
||||||
|
function SelectedServerProviders(props: ParentProps) {
|
||||||
|
return (
|
||||||
|
<ServerKey>
|
||||||
|
<ServerSDKProvider>
|
||||||
|
<ServerSyncProvider>{props.children}</ServerSyncProvider>
|
||||||
|
</ServerSDKProvider>
|
||||||
|
</ServerKey>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegacyServerLayout(props: ParentProps) {
|
||||||
|
return (
|
||||||
|
<SelectedServerProviders>
|
||||||
|
<LegacyServerScopedShell>{props.children}</LegacyServerScopedShell>
|
||||||
|
</SelectedServerProviders>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function DraftRoute() {
|
function DraftRoute() {
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
return (
|
return (
|
||||||
<Show when={tabs.ready()}>
|
<Show when={tabs.ready()}>
|
||||||
<Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
|
<Show
|
||||||
{(draftID) => <ResolvedDraftRoute draftID={draftID} />}
|
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
|
||||||
|
keyed
|
||||||
|
fallback={<Navigate href="/" />}
|
||||||
|
>
|
||||||
|
{(draft) => <ResolvedDraftRoute draft={draft} />}
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResolvedDraftRoute(props: { draftID: string }) {
|
function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const tabs = useTabs()
|
const conn = createMemo(() => server.list.find((item) => ServerConnection.key(item) === props.draft.server))
|
||||||
const draft = createMemo(() =>
|
const directory = () => props.draft.directory
|
||||||
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
|
const serverKey = () => props.draft.server
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const current = draft()
|
|
||||||
if (current && current.server !== server.key) server.setActive(current.server)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Key on the directory so retargeting the draft's project re-instantiates the
|
|
||||||
// SDK/data providers for the new directory while keeping the same draft id.
|
|
||||||
const directory = () => draft()?.directory
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={directory()} keyed>
|
<ServerSDKProvider server={conn}>
|
||||||
{(dir) => (
|
<ServerSyncProvider server={conn}>
|
||||||
<SDKProvider directory={dir}>
|
<TargetServerScopedProviders directory={directory}>
|
||||||
<DirectoryDataProvider directory={dir} draftID={props.draftID}>
|
<SDKProvider directory={directory}>
|
||||||
<DraftProviders>
|
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||||
<NewSession />
|
<DraftProviders>
|
||||||
</DraftProviders>
|
<NewSession />
|
||||||
</DirectoryDataProvider>
|
</DraftProviders>
|
||||||
</SDKProvider>
|
</DirectoryDataProvider>
|
||||||
)}
|
</SDKProvider>
|
||||||
</Show>
|
</TargetServerScopedProviders>
|
||||||
|
</ServerSyncProvider>
|
||||||
|
</ServerSDKProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,24 +289,62 @@ function BodyDesignClass() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppShellProviders(props: ParentProps) {
|
// Server-agnostic providers shared across every route. These live in the shared
|
||||||
|
// shell (router root) so they stay mounted regardless of the active server/route.
|
||||||
|
function SharedProviders(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<SettingsProvider>
|
<>
|
||||||
<BodyDesignClass />
|
<BodyDesignClass />
|
||||||
<PermissionProvider>
|
<CommandProvider>
|
||||||
<LayoutProvider>
|
<HighlightsProvider>{props.children}</HighlightsProvider>
|
||||||
<NotificationProvider>
|
</CommandProvider>
|
||||||
<ModelsProvider>
|
</>
|
||||||
<CommandProvider>
|
)
|
||||||
<HighlightsProvider>
|
}
|
||||||
<Layout>{props.children}</Layout>
|
|
||||||
</HighlightsProvider>
|
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
||||||
</CommandProvider>
|
type ServerScopedShellProps = ParentProps<{
|
||||||
</ModelsProvider>
|
directory?: () => string | undefined
|
||||||
</NotificationProvider>
|
sessionID?: () => string | undefined
|
||||||
</LayoutProvider>
|
}>
|
||||||
</PermissionProvider>
|
|
||||||
</SettingsProvider>
|
function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||||
|
return (
|
||||||
|
<PermissionProvider directory={props.directory}>
|
||||||
|
<LayoutProvider>
|
||||||
|
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
||||||
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||||
|
</NotificationProvider>
|
||||||
|
</LayoutProvider>
|
||||||
|
</PermissionProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
||||||
|
return (
|
||||||
|
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
|
||||||
|
<LegacyLayout>{props.children}</LegacyLayout>
|
||||||
|
</ServerScopedProviders>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewAppLayout(props: ParentProps) {
|
||||||
|
return (
|
||||||
|
<SelectedServerProviders>
|
||||||
|
<ServerScopedProviders>
|
||||||
|
<NewLayout>{props.children}</NewLayout>
|
||||||
|
</ServerScopedProviders>
|
||||||
|
</SelectedServerProviders>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TargetServerScopedProviders(props: ServerScopedShellProps) {
|
||||||
|
return (
|
||||||
|
<PermissionProvider directory={props.directory}>
|
||||||
|
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
||||||
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||||
|
</NotificationProvider>
|
||||||
|
</PermissionProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,17 +372,6 @@ function DraftProviders(props: ParentProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
|
||||||
return (
|
|
||||||
<AppShellProviders>
|
|
||||||
{/*<Suspense fallback={<Loading />}>*/}
|
|
||||||
{props.appChildren}
|
|
||||||
{props.children}
|
|
||||||
{/*</Suspense>*/}
|
|
||||||
</AppShellProviders>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||||
return (
|
return (
|
||||||
<MetaProvider>
|
<MetaProvider>
|
||||||
@@ -385,6 +530,18 @@ export function AppInterface(props: {
|
|||||||
router?: Component<BaseRouterProps>
|
router?: Component<BaseRouterProps>
|
||||||
disableHealthCheck?: boolean
|
disableHealthCheck?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
// The visual new layout lives in the router root so it remains mounted across
|
||||||
|
// route changes. Draft and session routes override only their server-bound data
|
||||||
|
// providers beneath it.
|
||||||
|
const ServerShell = (shellProps: ParentProps) => (
|
||||||
|
<QueryProvider>
|
||||||
|
<SharedProviders>
|
||||||
|
{props.children}
|
||||||
|
{shellProps.children}
|
||||||
|
</SharedProviders>
|
||||||
|
</QueryProvider>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ServerProvider
|
<ServerProvider
|
||||||
defaultServer={props.defaultServer}
|
defaultServer={props.defaultServer}
|
||||||
@@ -392,32 +549,57 @@ export function AppInterface(props: {
|
|||||||
servers={props.servers}
|
servers={props.servers}
|
||||||
>
|
>
|
||||||
<GlobalProvider>
|
<GlobalProvider>
|
||||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
<SettingsProvider>
|
||||||
<Dynamic
|
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||||
component={props.router ?? Router}
|
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
|
||||||
root={(routerProps) => (
|
<Dynamic
|
||||||
<TabsProvider>
|
component={props.router ?? Router}
|
||||||
<ServerKey>
|
root={(routerProps) => (
|
||||||
<QueryProvider>
|
<TabsProvider>
|
||||||
<ServerSDKProvider>
|
<ServerShell>
|
||||||
<ServerSyncProvider>
|
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||||
<RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>
|
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
||||||
</ServerSyncProvider>
|
</Show>
|
||||||
</ServerSDKProvider>
|
</ServerShell>
|
||||||
</QueryProvider>
|
</TabsProvider>
|
||||||
</ServerKey>
|
)}
|
||||||
</TabsProvider>
|
>
|
||||||
)}
|
<Routes />
|
||||||
>
|
</Dynamic>
|
||||||
<Route path="/" component={HomeRoute} />
|
</Show>
|
||||||
<Route path="/new-session" component={DraftRoute} />
|
</ConnectionGate>
|
||||||
<Route path="/:dir" component={DirectoryLayout}>
|
</SettingsProvider>
|
||||||
<Route path="/" component={() => <Navigate href="session" />} />
|
|
||||||
<Route path="/session/:id?" component={SessionRoute} />
|
|
||||||
</Route>
|
|
||||||
</Dynamic>
|
|
||||||
</ConnectionGate>
|
|
||||||
</GlobalProvider>
|
</GlobalProvider>
|
||||||
</ServerProvider>
|
</ServerProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Routes() {
|
||||||
|
const settings = useSettings()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Route component={LegacyServerLayout}>
|
||||||
|
<Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
|
||||||
|
<Route path="/:dir" component={DirectoryLayout}>
|
||||||
|
<Route path="/" component={() => <Navigate href="session" />} />
|
||||||
|
<Route path="/session/:id?" component={SessionRoute} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
<Show when={settings.general.newLayoutDesigns()}>
|
||||||
|
<Route path="/" component={NewHome} />
|
||||||
|
<Route
|
||||||
|
path="/:dir/session/:id"
|
||||||
|
component={() => {
|
||||||
|
const server = useServer()
|
||||||
|
const { id } = useParams()
|
||||||
|
|
||||||
|
return <Navigate href={`/server/${server.key}/session/${id}`} />
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<Route path="/new-session" component={DraftRoute} />
|
||||||
|
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { newTabTooltipKeybind, reviewTooltipKeybind } from "./command-tooltip-keybind"
|
||||||
|
|
||||||
|
describe("command tooltip keybinds", () => {
|
||||||
|
test("keeps localized review shortcut modifiers", () => {
|
||||||
|
const command = {
|
||||||
|
keybind: () => "Ctrl+Maj+R",
|
||||||
|
keybindParts: () => ["Ctrl", "Maj", "R"],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(reviewTooltipKeybind(command, (key) => key)).toEqual(["Ctrl", "Maj", "R"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses the configured new-tab shortcut", () => {
|
||||||
|
const command = {
|
||||||
|
keybind: () => "Alt+N",
|
||||||
|
keybindParts: () => ["Alt", "N"],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(newTabTooltipKeybind(command, (key) => key)).toEqual(["Alt", "N"])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
type CommandKeybind = {
|
||||||
|
keybindParts: (id: string) => string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reviewTooltipKeybind(command: CommandKeybind, _translate?: (key: string) => string) {
|
||||||
|
return command.keybindParts("review.toggle")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newTabTooltipKeybind(command: CommandKeybind, _translate?: (key: string) => string) {
|
||||||
|
return command.keybindParts("tab.new")
|
||||||
|
}
|
||||||
@@ -363,7 +363,7 @@ export function DebugBar() {
|
|||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
aria-label={language.t("debugBar.ariaLabel")}
|
aria-label={language.t("debugBar.ariaLabel")}
|
||||||
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
|
class="pointer-events-auto fixed bottom-3 right-3 z-50 hidden w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] md:block sm:bottom-4 sm:right-4 sm:w-[324px]"
|
||||||
>
|
>
|
||||||
<div class="grid grid-cols-5 gap-px font-mono">
|
<div class="grid grid-cols-5 gap-px font-mono">
|
||||||
<Cell
|
<Cell
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
|
import { type Accessor, createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
@@ -17,16 +17,16 @@ import { useServerSync } from "@/context/server-sync"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
|
|
||||||
export function DialogConnectProvider(props: { provider: string }) {
|
export function DialogConnectProvider(props: { provider: string; directory?: Accessor<string | undefined> }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const providers = useProviders()
|
const providers = useProviders(props.directory)
|
||||||
|
|
||||||
const all = () => {
|
const all = () => {
|
||||||
void import("./dialog-select-provider").then((x) => {
|
void import("./dialog-select-provider").then((x) => {
|
||||||
dialog.show(() => <x.DialogSelectProvider />)
|
dialog.show(() => <x.DialogSelectProvider directory={props.directory} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const provider = createMemo(
|
const provider = createMemo(
|
||||||
() => providers.all().get(props.provider) ?? serverSync.data.provider.all.get(props.provider)!,
|
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
||||||
)
|
)
|
||||||
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
||||||
{
|
{
|
||||||
@@ -52,16 +52,16 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
const [auth] = createResource(
|
const [auth] = createResource(
|
||||||
() => props.provider,
|
() => props.provider,
|
||||||
async () => {
|
async () => {
|
||||||
const cached = serverSync.data.provider_auth[props.provider]
|
const cached = serverSync().data.provider_auth[props.provider]
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
const res = await serverSDK.client.provider.auth()
|
const res = await serverSDK().client.provider.auth()
|
||||||
if (!alive.value) return fallback()
|
if (!alive.value) return fallback()
|
||||||
serverSync.set("provider_auth", res.data ?? {})
|
serverSync().set("provider_auth", res.data ?? {})
|
||||||
return res.data?.[props.provider] ?? fallback()
|
return res.data?.[props.provider] ?? fallback()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const loading = createMemo(() => auth.loading && !serverSync.data.provider_auth[props.provider])
|
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
|
||||||
const methods = createMemo(() => auth.latest ?? serverSync.data.provider_auth[props.provider] ?? fallback())
|
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
methodIndex: undefined as undefined | number,
|
methodIndex: undefined as undefined | number,
|
||||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||||
@@ -158,8 +158,8 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
}
|
}
|
||||||
dispatch({ type: "auth.pending" })
|
dispatch({ type: "auth.pending" })
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
await serverSDK.client.provider.oauth
|
await serverSDK()
|
||||||
.authorize(
|
.client.provider.oauth.authorize(
|
||||||
{
|
{
|
||||||
providerID: props.provider,
|
providerID: props.provider,
|
||||||
method: index,
|
method: index,
|
||||||
@@ -331,7 +331,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function complete() {
|
async function complete() {
|
||||||
await serverSDK.client.global.dispose()
|
await serverSDK().client.global.dispose()
|
||||||
dialog.close()
|
dialog.close()
|
||||||
showToast({
|
showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
@@ -409,7 +409,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
setFormStore("error", undefined)
|
||||||
await serverSDK.client.auth.set({
|
await serverSDK().client.auth.set({
|
||||||
providerID: props.provider,
|
providerID: props.provider,
|
||||||
auth: {
|
auth: {
|
||||||
type: "api",
|
type: "api",
|
||||||
@@ -480,8 +480,8 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
setFormStore("error", undefined)
|
||||||
const result = await serverSDK.client.provider.oauth
|
const result = await serverSDK()
|
||||||
.callback({
|
.client.provider.oauth.callback({
|
||||||
providerID: props.provider,
|
providerID: props.provider,
|
||||||
method: store.methodIndex,
|
method: store.methodIndex,
|
||||||
code,
|
code,
|
||||||
@@ -533,8 +533,8 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const result = await serverSDK.client.provider.oauth
|
const result = await serverSDK()
|
||||||
.callback({
|
.client.provider.oauth.callback({
|
||||||
providerID: props.provider,
|
providerID: props.provider,
|
||||||
method: store.methodIndex,
|
method: store.methodIndex,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|||||||
import { useMutation } from "@tanstack/solid-query"
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { batch, For } from "solid-js"
|
import { type Accessor, batch, For } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
@@ -17,6 +17,7 @@ import { DialogSelectProvider } from "./dialog-select-provider"
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
back?: "providers" | "close"
|
back?: "providers" | "close"
|
||||||
|
directory?: Accessor<string | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DialogCustomProvider(props: Props) {
|
export function DialogCustomProvider(props: Props) {
|
||||||
@@ -40,7 +41,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
dialog.close()
|
dialog.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dialog.show(() => <DialogSelectProvider />)
|
dialog.show(() => <DialogSelectProvider directory={props.directory} />)
|
||||||
}
|
}
|
||||||
|
|
||||||
const addModel = () => {
|
const addModel = () => {
|
||||||
@@ -105,8 +106,8 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
const output = validateCustomProvider({
|
const output = validateCustomProvider({
|
||||||
form,
|
form,
|
||||||
t: language.t,
|
t: language.t,
|
||||||
disabledProviders: serverSync.data.config.disabled_providers ?? [],
|
disabledProviders: serverSync().data.config.disabled_providers ?? [],
|
||||||
existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
|
existingProviderIDs: new Set(serverSync().data.provider.all.keys()),
|
||||||
})
|
})
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setForm("err", output.err)
|
setForm("err", output.err)
|
||||||
@@ -118,11 +119,11 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
|
|
||||||
const saveMutation = useMutation(() => ({
|
const saveMutation = useMutation(() => ({
|
||||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||||
const disabledProviders = serverSync.data.config.disabled_providers ?? []
|
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
||||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||||
|
|
||||||
if (result.key) {
|
if (result.key) {
|
||||||
await serverSDK.client.auth.set({
|
await serverSDK().client.auth.set({
|
||||||
providerID: result.providerID,
|
providerID: result.providerID,
|
||||||
auth: {
|
auth: {
|
||||||
type: "api",
|
type: "api",
|
||||||
@@ -131,7 +132,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await serverSync.updateConfig({
|
await serverSync().updateConfig({
|
||||||
provider: { [result.providerID]: result.config },
|
provider: { [result.providerID]: result.config },
|
||||||
disabled_providers: nextDisabled,
|
disabled_providers: nextDisabled,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverCtx = createMemo(() => global.createServerCtx(props.server))
|
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
||||||
const serverSDK = () => serverCtx().sdk
|
const serverSDK = () => serverCtx().sdk
|
||||||
const serverSync = () => serverCtx().sync
|
const serverSync = () => serverCtx().sync
|
||||||
|
|
||||||
|
|||||||
@@ -35,13 +35,13 @@ export const DialogFork: Component = () => {
|
|||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return []
|
if (!sessionID) return []
|
||||||
|
|
||||||
const msgs = sync.data.message[sessionID] ?? []
|
const msgs = sync().data.message[sessionID] ?? []
|
||||||
const result: ForkableMessage[] = []
|
const result: ForkableMessage[] = []
|
||||||
|
|
||||||
for (const message of msgs) {
|
for (const message of msgs) {
|
||||||
if (message.role !== "user") continue
|
if (message.role !== "user") continue
|
||||||
|
|
||||||
const parts = sync.data.part[message.id] ?? []
|
const parts = sync().data.part[message.id] ?? []
|
||||||
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
|
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
|
||||||
if (!textPart) continue
|
if (!textPart) continue
|
||||||
|
|
||||||
@@ -61,15 +61,15 @@ export const DialogFork: Component = () => {
|
|||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
const parts = sync.data.part[item.id] ?? []
|
const parts = sync().data.part[item.id] ?? []
|
||||||
const restored = extractPromptFromParts(parts, {
|
const restored = extractPromptFromParts(parts, {
|
||||||
directory: sdk.directory,
|
directory: sdk().directory,
|
||||||
attachmentName: language.t("common.attachment"),
|
attachmentName: language.t("common.attachment"),
|
||||||
})
|
})
|
||||||
const dir = base64Encode(sdk.directory)
|
const dir = base64Encode(sdk().directory)
|
||||||
|
|
||||||
sdk.client.session
|
sdk()
|
||||||
.fork({ sessionID, messageID: item.id })
|
.client.session.fork({ sessionID, messageID: item.id })
|
||||||
.then((forked) => {
|
.then((forked) => {
|
||||||
if (!forked.data) {
|
if (!forked.data) {
|
||||||
showToast({ title: language.t("common.requestFailed") })
|
showToast({ title: language.t("common.requestFailed") })
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ import { popularProviders } from "@/hooks/use-providers"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
export const DialogManageModels: Component = () => {
|
export const DialogManageModels: Component = () => {
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const directory = () => decode64(local.slug())
|
||||||
|
|
||||||
const handleConnectProvider = () => {
|
const handleConnectProvider = () => {
|
||||||
dialog.show(() => <DialogSelectProvider />)
|
dialog.show(() => <DialogSelectProvider directory={directory} />)
|
||||||
}
|
}
|
||||||
const providerRank = (id: string) => popularProviders.indexOf(id)
|
const providerRank = (id: string) => popularProviders.indexOf(id)
|
||||||
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
.directory-picker-v2-body {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 2px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directory-picker-v2-path {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directory-picker-v2-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directory-picker-v2-suggestions {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
top: 36px;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid var(--v2-border-border-base);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--v2-background-bg-layer-02);
|
||||||
|
box-shadow: var(--v2-elevation-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.directory-picker-v2-suggestions button {
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: left;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directory-picker-v2-suggestions button:hover,
|
||||||
|
.directory-picker-v2-suggestions button[data-active] {
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
background: var(--v2-overlay-simple-overlay-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.directory-picker-v2-browser {
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
isolation: isolate;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--v2-border-border-base);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directory-picker-v2-tree {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
--trees-bg-override: transparent;
|
||||||
|
--trees-fg-override: var(--v2-text-text-base);
|
||||||
|
--trees-fg-muted-override: var(--v2-text-text-muted);
|
||||||
|
--trees-bg-muted-override: transparent;
|
||||||
|
--trees-selected-bg-override: transparent;
|
||||||
|
--trees-selected-fg-override: var(--v2-text-text-base);
|
||||||
|
--trees-selected-focused-border-color-override: transparent;
|
||||||
|
--trees-focus-ring-color-override: transparent;
|
||||||
|
--trees-focus-ring-width-override: 0px;
|
||||||
|
--trees-focus-ring-offset-override: 0px;
|
||||||
|
--trees-border-color-override: var(--v2-border-border-base);
|
||||||
|
--trees-font-family-override: var(--font-family-sans);
|
||||||
|
--trees-font-size-override: 12px;
|
||||||
|
--trees-item-height: 24px;
|
||||||
|
--trees-border-radius-override: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directory-picker-v2-state {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.directory-picker-v2-selection {
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
import "@pierre/trees/web-components"
|
||||||
|
import { FileTree } from "@pierre/trees"
|
||||||
|
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"
|
||||||
|
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import {
|
||||||
|
absoluteTreePath,
|
||||||
|
activeTreeNavigation,
|
||||||
|
advanceTreePreload,
|
||||||
|
nextSuggestionIndex,
|
||||||
|
nextTreeScrollTop,
|
||||||
|
pickerFileSearchQuery,
|
||||||
|
pickerAbsoluteInput,
|
||||||
|
pickerMode,
|
||||||
|
preloadTreeDirectories,
|
||||||
|
cleanPickerInput,
|
||||||
|
createPriorityTaskQueue,
|
||||||
|
createDirectorySearch,
|
||||||
|
currentPickerSuggestions,
|
||||||
|
displayPickerPath,
|
||||||
|
pickerParent,
|
||||||
|
pickerRoot,
|
||||||
|
} from "./directory-picker-domain"
|
||||||
|
import "./dialog-select-directory-v2.css"
|
||||||
|
|
||||||
|
interface DialogSelectDirectoryV2Props {
|
||||||
|
title?: string
|
||||||
|
multiple?: boolean
|
||||||
|
onSelect: (result: string | string[] | null) => void
|
||||||
|
server: ServerConnection.Any
|
||||||
|
mode?: "directory" | "file"
|
||||||
|
start?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||||
|
const global = useGlobal()
|
||||||
|
const { sync, sdk } = global.ensureServerCtx(props.server)
|
||||||
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
|
const policy = pickerMode(props.mode ?? "directory", props.start)
|
||||||
|
const action = {
|
||||||
|
file: language.t("dialog.directory.action.selectFile"),
|
||||||
|
directory: language.t("dialog.directory.action.selectFolder"),
|
||||||
|
}
|
||||||
|
const [root, setRoot] = createSignal("")
|
||||||
|
const [input, setInput] = createSignal("")
|
||||||
|
const [selected, setSelected] = createSignal("")
|
||||||
|
const [suggestionsOpen, setSuggestionsOpen] = createSignal(false)
|
||||||
|
const [activeSuggestion, setActiveSuggestion] = createSignal(-1)
|
||||||
|
const [loading, setLoading] = createSignal(false)
|
||||||
|
const [error, setError] = createSignal(false)
|
||||||
|
const [rootValid, setRootValid] = createSignal(false)
|
||||||
|
const listings = new Map<string, Promise<Array<{ name: string; type: "file" | "directory" }> | undefined>>()
|
||||||
|
const loads = createPriorityTaskQueue<Array<{ name: string; type: "file" | "directory" }> | undefined>(3)
|
||||||
|
const advanced = new Set<string>()
|
||||||
|
let tree: FileTree | undefined
|
||||||
|
let container: HTMLDivElement | undefined
|
||||||
|
let pathArea: HTMLDivElement | undefined
|
||||||
|
let navigation = 0
|
||||||
|
|
||||||
|
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||||
|
const [fallbackPath] = createResource(
|
||||||
|
() => (missingBase() ? true : undefined),
|
||||||
|
() =>
|
||||||
|
sdk.client.path
|
||||||
|
.get()
|
||||||
|
.then((result) => result.data)
|
||||||
|
.catch(() => undefined),
|
||||||
|
{ initialValue: undefined },
|
||||||
|
)
|
||||||
|
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
||||||
|
const start = createMemo(
|
||||||
|
() =>
|
||||||
|
props.start ||
|
||||||
|
sync.data.path.home ||
|
||||||
|
sync.data.path.directory ||
|
||||||
|
fallbackPath()?.home ||
|
||||||
|
fallbackPath()?.directory,
|
||||||
|
)
|
||||||
|
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
||||||
|
const [suggestions] = createResource(input, async (value) => {
|
||||||
|
const typed = cleanPickerInput(value).replace(/\/+$/, "")
|
||||||
|
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
|
||||||
|
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 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((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })),
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
query: value,
|
||||||
|
items: Array.from(new Map(results.map((result) => [result.absolute, result])).values()).slice(0, 8),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input()))
|
||||||
|
|
||||||
|
async function load(path: string, generation: number, eager = false) {
|
||||||
|
const key = path.replace(/\/+$/, "")
|
||||||
|
setError(false)
|
||||||
|
const absolute = absoluteTreePath(root(), key)
|
||||||
|
const existing = listings.get(key)
|
||||||
|
if (existing && !eager) loads.promote(`${generation}:${key}`)
|
||||||
|
const request =
|
||||||
|
existing ??
|
||||||
|
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||||
|
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||||
|
return sdk.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
|
||||||
|
if (!nodes) {
|
||||||
|
listings.delete(key)
|
||||||
|
if (!key) setError(true)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item })))
|
||||||
|
if (!eager && advanceTreePreload(advanced, key)) {
|
||||||
|
for (const directory of preloadTreeDirectories(key, nodes)) void load(directory, generation, true)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigate(path: string) {
|
||||||
|
const value = policy.navigation(pickerAbsoluteInput(cleanPickerInput(path), home(), root() || start() || home()))
|
||||||
|
if (!value) return
|
||||||
|
const token = ++navigation
|
||||||
|
setLoading(true)
|
||||||
|
setRootValid(false)
|
||||||
|
setSelected("")
|
||||||
|
setSuggestionsOpen(false)
|
||||||
|
setActiveSuggestion(-1)
|
||||||
|
setRoot(value)
|
||||||
|
setInput(displayPickerPath(value, value, home()))
|
||||||
|
listings.clear()
|
||||||
|
advanced.clear()
|
||||||
|
tree?.resetPaths([])
|
||||||
|
const valid = await load("", token)
|
||||||
|
if (!activeTreeNavigation(token, navigation)) return
|
||||||
|
setRootValid(valid)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function complete() {
|
||||||
|
const items = currentSuggestions()
|
||||||
|
const match = items[activeSuggestion()] ?? items[0]
|
||||||
|
if (!match) return
|
||||||
|
const value = displayPickerPath(match.absolute, input(), home())
|
||||||
|
setInput(match.type === "directory" && !value.endsWith("/") ? value + "/" : value)
|
||||||
|
if (match.type === "file") {
|
||||||
|
setSelected(policy.selection(root(), pickerFileSearchQuery(root(), match.absolute, home())) ?? "")
|
||||||
|
setSuggestionsOpen(false)
|
||||||
|
setActiveSuggestion(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseSuggestion(suggestion: { absolute: string; type: "file" | "directory" }) {
|
||||||
|
if (suggestion.type === "directory") {
|
||||||
|
void navigate(suggestion.absolute)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setInput(displayPickerPath(suggestion.absolute, input(), home()))
|
||||||
|
setSelected(policy.selection(root(), pickerFileSearchQuery(root(), suggestion.absolute, home())) ?? "")
|
||||||
|
setSuggestionsOpen(false)
|
||||||
|
setActiveSuggestion(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveSuggestion(delta: -1 | 1) {
|
||||||
|
setSuggestionsOpen(true)
|
||||||
|
setActiveSuggestion((current) => nextSuggestionIndex(current, delta, currentSuggestions().length))
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeSuggestionValue() {
|
||||||
|
const items = currentSuggestions()
|
||||||
|
return items[activeSuggestion()] ?? items[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyActions: Partial<Record<string, () => void>> = {
|
||||||
|
ArrowDown: () => moveSuggestion(1),
|
||||||
|
ArrowUp: () => moveSuggestion(-1),
|
||||||
|
Enter: () => {
|
||||||
|
const suggestion = activeSuggestionValue()
|
||||||
|
if (suggestion) chooseSuggestion(suggestion)
|
||||||
|
if (!suggestion) void navigate(input())
|
||||||
|
},
|
||||||
|
Tab: complete,
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInputKey(event: KeyboardEvent) {
|
||||||
|
const action = keyActions[event.key]
|
||||||
|
if (!action) return
|
||||||
|
if (event.key === "Tab" && event.shiftKey) return
|
||||||
|
event.preventDefault()
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolve() {
|
||||||
|
const path = policy.result(root(), selected(), rootValid())
|
||||||
|
if (!path) return
|
||||||
|
props.onSelect(props.multiple ? [path] : path)
|
||||||
|
dialog.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const closeSuggestions = (event: PointerEvent) => {
|
||||||
|
if (pathArea?.contains(event.target as Node)) return
|
||||||
|
setSuggestionsOpen(false)
|
||||||
|
setActiveSuggestion(-1)
|
||||||
|
}
|
||||||
|
document.addEventListener("pointerdown", closeSuggestions)
|
||||||
|
onCleanup(() => document.removeEventListener("pointerdown", closeSuggestions))
|
||||||
|
tree = new FileTree({
|
||||||
|
paths: [],
|
||||||
|
flattenEmptyDirectories: false,
|
||||||
|
initialExpansion: "closed",
|
||||||
|
stickyFolders: true,
|
||||||
|
unsafeCSS: `
|
||||||
|
button[data-type="item"] {
|
||||||
|
background: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
button[data-type="item"]:hover {
|
||||||
|
background: var(--v2-overlay-simple-overlay-hover) !important;
|
||||||
|
}
|
||||||
|
button[data-type="item"]:focus-visible {
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
[data-file-tree-virtualized-scroll] {
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
onExpansionChange(change) {
|
||||||
|
if (change.expanded) void load(change.path, navigation)
|
||||||
|
},
|
||||||
|
onSelectionChange(paths) {
|
||||||
|
const path = paths.at(-1)
|
||||||
|
setSelected(path ? (policy.selection(root(), path) ?? "") : "")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!container) return
|
||||||
|
tree.render({ containerWrapper: container })
|
||||||
|
tree.getFileTreeContainer()?.classList.add("directory-picker-v2-tree")
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const path = start()
|
||||||
|
if (!path || root()) return
|
||||||
|
void navigate(path)
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => tree?.cleanUp())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title={props.title ?? language.t("command.project.open")} size="large" class="directory-picker-v2">
|
||||||
|
<div class="directory-picker-v2-body">
|
||||||
|
<div class="directory-picker-v2-path" ref={pathArea}>
|
||||||
|
<TextInputV2
|
||||||
|
value={input()}
|
||||||
|
autofocus
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck={false}
|
||||||
|
class="!w-full"
|
||||||
|
onInput={(event) => {
|
||||||
|
setInput(cleanPickerInput(event.currentTarget.value))
|
||||||
|
setSelected("")
|
||||||
|
setSuggestionsOpen(true)
|
||||||
|
setActiveSuggestion(-1)
|
||||||
|
}}
|
||||||
|
role="combobox"
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-expanded={suggestionsOpen()}
|
||||||
|
aria-controls="directory-picker-v2-suggestions"
|
||||||
|
aria-activedescendant={
|
||||||
|
activeSuggestion() >= 0 ? `directory-picker-v2-suggestion-${activeSuggestion()}` : undefined
|
||||||
|
}
|
||||||
|
onKeyDown={handleInputKey}
|
||||||
|
/>
|
||||||
|
<div class="directory-picker-v2-actions">
|
||||||
|
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(home())}>
|
||||||
|
~
|
||||||
|
</ButtonV2>
|
||||||
|
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(pickerRoot(root()) || root())}>
|
||||||
|
{language.t("dialog.directory.root")}
|
||||||
|
</ButtonV2>
|
||||||
|
<ButtonV2 size="small" variant="ghost" onClick={() => void navigate(pickerParent(root()))}>
|
||||||
|
{language.t("dialog.directory.parent")}
|
||||||
|
</ButtonV2>
|
||||||
|
</div>
|
||||||
|
<Show when={suggestionsOpen() && currentSuggestions().length > 0}>
|
||||||
|
<div id="directory-picker-v2-suggestions" role="listbox" class="directory-picker-v2-suggestions">
|
||||||
|
<For each={currentSuggestions()}>
|
||||||
|
{(suggestion, index) => (
|
||||||
|
<button
|
||||||
|
id={`directory-picker-v2-suggestion-${index()}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={index() === activeSuggestion()}
|
||||||
|
data-active={index() === activeSuggestion() ? "" : undefined}
|
||||||
|
onPointerMove={() => setActiveSuggestion(index())}
|
||||||
|
onClick={() => chooseSuggestion(suggestion)}
|
||||||
|
>
|
||||||
|
{displayPickerPath(suggestion.absolute, input(), home())}
|
||||||
|
{suggestion.type === "directory" ? "/" : ""}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="directory-picker-v2-browser"
|
||||||
|
ref={container}
|
||||||
|
onWheel={(event) => {
|
||||||
|
const scroller = tree
|
||||||
|
?.getFileTreeContainer()
|
||||||
|
?.shadowRoot?.querySelector<HTMLElement>("[data-file-tree-virtualized-scroll]")
|
||||||
|
if (!scroller) return
|
||||||
|
const next = nextTreeScrollTop(
|
||||||
|
scroller.scrollTop,
|
||||||
|
event.deltaY,
|
||||||
|
scroller.scrollHeight,
|
||||||
|
scroller.clientHeight,
|
||||||
|
)
|
||||||
|
if (next === scroller.scrollTop) return
|
||||||
|
event.preventDefault()
|
||||||
|
scroller.scrollTop = next
|
||||||
|
scroller.dispatchEvent(new Event("scroll"))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={loading()}>
|
||||||
|
<div class="directory-picker-v2-state">{language.t("common.loading")}</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={!loading() && error()}>
|
||||||
|
<div class="directory-picker-v2-state">{language.t("dialog.directory.readError")}</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="directory-picker-v2-selection">{policy.result(root(), selected(), rootValid())}</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
|
||||||
|
{language.t("common.cancel")}
|
||||||
|
</ButtonV2>
|
||||||
|
<ButtonV2 variant="contrast" disabled={!policy.result(root(), selected(), rootValid())} onClick={resolve}>
|
||||||
|
{action[policy.action]}
|
||||||
|
</ButtonV2>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,12 +4,11 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
|
|||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import type { ListRef } from "@opencode-ai/ui/list"
|
import type { ListRef } from "@opencode-ai/ui/list"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
import fuzzysort from "fuzzysort"
|
|
||||||
import { createMemo, createResource, createSignal } from "solid-js"
|
import { createMemo, createResource, createSignal } from "solid-js"
|
||||||
import { ServerSDK } from "@/context/server-sdk"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
|
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
|
||||||
|
|
||||||
interface DialogSelectDirectoryProps {
|
interface DialogSelectDirectoryProps {
|
||||||
title?: string
|
title?: string
|
||||||
@@ -24,89 +23,9 @@ type Row = {
|
|||||||
group: "recent" | "folders"
|
group: "recent" | "folders"
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanInput(value: string) {
|
|
||||||
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
|
||||||
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePath(input: string) {
|
|
||||||
const v = input.replaceAll("\\", "/")
|
|
||||||
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
|
|
||||||
return v.replace(/\/+/g, "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDriveRoot(input: string) {
|
|
||||||
const v = normalizePath(input)
|
|
||||||
if (/^[A-Za-z]:$/.test(v)) return v + "/"
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
function trimTrailing(input: string) {
|
|
||||||
const v = normalizeDriveRoot(input)
|
|
||||||
if (v === "/") return v
|
|
||||||
if (v === "//") return v
|
|
||||||
if (/^[A-Za-z]:\/$/.test(v)) return v
|
|
||||||
return v.replace(/\/+$/, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function joinPath(base: string | undefined, rel: string) {
|
|
||||||
const b = trimTrailing(base ?? "")
|
|
||||||
const r = trimTrailing(rel).replace(/^\/+/, "")
|
|
||||||
if (!b) return r
|
|
||||||
if (!r) return b
|
|
||||||
if (b.endsWith("/")) return b + r
|
|
||||||
return b + "/" + r
|
|
||||||
}
|
|
||||||
|
|
||||||
function rootOf(input: string) {
|
|
||||||
const v = normalizeDriveRoot(input)
|
|
||||||
if (v.startsWith("//")) return "//"
|
|
||||||
if (v.startsWith("/")) return "/"
|
|
||||||
if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function parentOf(input: string) {
|
|
||||||
const v = trimTrailing(input)
|
|
||||||
if (v === "/") return v
|
|
||||||
if (v === "//") return v
|
|
||||||
if (/^[A-Za-z]:\/$/.test(v)) return v
|
|
||||||
|
|
||||||
const i = v.lastIndexOf("/")
|
|
||||||
if (i <= 0) return "/"
|
|
||||||
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
|
|
||||||
return v.slice(0, i)
|
|
||||||
}
|
|
||||||
|
|
||||||
function modeOf(input: string) {
|
|
||||||
const raw = normalizeDriveRoot(input.trim())
|
|
||||||
if (!raw) return "relative" as const
|
|
||||||
if (raw.startsWith("~")) return "tilde" as const
|
|
||||||
if (rootOf(raw)) return "absolute" as const
|
|
||||||
return "relative" as const
|
|
||||||
}
|
|
||||||
|
|
||||||
function tildeOf(absolute: string, home: string) {
|
|
||||||
const full = trimTrailing(absolute)
|
|
||||||
if (!home) return ""
|
|
||||||
|
|
||||||
const hn = trimTrailing(home)
|
|
||||||
const lc = full.toLowerCase()
|
|
||||||
const hc = hn.toLowerCase()
|
|
||||||
if (lc === hc) return "~"
|
|
||||||
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayPath(path: string, input: string, home: string) {
|
|
||||||
const full = trimTrailing(path)
|
|
||||||
if (modeOf(input) === "absolute") return full
|
|
||||||
return tildeOf(full, home) || full
|
|
||||||
}
|
|
||||||
|
|
||||||
function toRow(absolute: string, home: string, group: Row["group"]): Row {
|
function toRow(absolute: string, home: string, group: Row["group"]): Row {
|
||||||
const full = trimTrailing(absolute)
|
const full = displayPickerPath(absolute, "", "")
|
||||||
const tilde = tildeOf(full, home)
|
const tilde = displayPickerPath(full, "~", home)
|
||||||
const withSlash = (value: string) => {
|
const withSlash = (value: string) => {
|
||||||
if (!value) return ""
|
if (!value) return ""
|
||||||
if (value.endsWith("/")) return value
|
if (value.endsWith("/")) return value
|
||||||
@@ -128,123 +47,9 @@ function uniqueRows(rows: Row[]) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function useDirectorySearch(args: { sdk: ServerSDK; start: () => string | undefined; home: () => string }) {
|
|
||||||
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
|
||||||
let current = 0
|
|
||||||
|
|
||||||
const scoped = (value: string) => {
|
|
||||||
const base = args.start()
|
|
||||||
if (!base) return
|
|
||||||
|
|
||||||
const raw = normalizeDriveRoot(value)
|
|
||||||
if (!raw) return { directory: trimTrailing(base), path: "" }
|
|
||||||
|
|
||||||
const h = args.home()
|
|
||||||
if (raw === "~") return { directory: trimTrailing(h || base), path: "" }
|
|
||||||
if (raw.startsWith("~/")) return { directory: trimTrailing(h || base), path: raw.slice(2) }
|
|
||||||
|
|
||||||
const root = rootOf(raw)
|
|
||||||
if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) }
|
|
||||||
return { directory: trimTrailing(base), path: raw }
|
|
||||||
}
|
|
||||||
|
|
||||||
const dirs = async (dir: string) => {
|
|
||||||
const key = trimTrailing(dir)
|
|
||||||
const existing = cache.get(key)
|
|
||||||
if (existing) return existing
|
|
||||||
|
|
||||||
const request = args.sdk.client.file
|
|
||||||
.list({ directory: key, path: "" })
|
|
||||||
.then((x) => x.data ?? [])
|
|
||||||
.catch(() => [])
|
|
||||||
.then((nodes) =>
|
|
||||||
nodes
|
|
||||||
.filter((n) => n.type === "directory")
|
|
||||||
.map((n) => ({
|
|
||||||
name: n.name,
|
|
||||||
absolute: trimTrailing(normalizeDriveRoot(n.absolute)),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
cache.set(key, request)
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
|
|
||||||
const match = async (dir: string, query: string, limit: number) => {
|
|
||||||
const items = await dirs(dir)
|
|
||||||
if (!query) return items.slice(0, limit).map((x) => x.absolute)
|
|
||||||
return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute)
|
|
||||||
}
|
|
||||||
|
|
||||||
return async (filter: string) => {
|
|
||||||
const token = ++current
|
|
||||||
const active = () => token === current
|
|
||||||
|
|
||||||
const value = cleanInput(filter)
|
|
||||||
const scopedInput = scoped(value)
|
|
||||||
if (!scopedInput) return [] as string[]
|
|
||||||
|
|
||||||
const raw = normalizeDriveRoot(value)
|
|
||||||
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
|
|
||||||
const query = normalizeDriveRoot(scopedInput.path)
|
|
||||||
|
|
||||||
const find = () =>
|
|
||||||
args.sdk.client.find
|
|
||||||
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
|
|
||||||
.then((x) => x.data ?? [])
|
|
||||||
.catch(() => [])
|
|
||||||
|
|
||||||
if (!isPath) {
|
|
||||||
const results = await find()
|
|
||||||
if (!active()) return []
|
|
||||||
return results.map((rel) => joinPath(scopedInput.directory, rel)).slice(0, 50)
|
|
||||||
}
|
|
||||||
|
|
||||||
const segments = query.replace(/^\/+/, "").split("/")
|
|
||||||
const head = segments.slice(0, segments.length - 1).filter((x) => x && x !== ".")
|
|
||||||
const tail = segments[segments.length - 1] ?? ""
|
|
||||||
|
|
||||||
const cap = 12
|
|
||||||
const branch = 4
|
|
||||||
let paths = [scopedInput.directory]
|
|
||||||
for (const part of head) {
|
|
||||||
if (!active()) return []
|
|
||||||
if (part === "..") {
|
|
||||||
paths = paths.map(parentOf)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat()
|
|
||||||
if (!active()) return []
|
|
||||||
paths = Array.from(new Set(next)).slice(0, cap)
|
|
||||||
if (paths.length === 0) return [] as string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
|
|
||||||
if (!active()) return []
|
|
||||||
const deduped = Array.from(new Set(out))
|
|
||||||
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
|
|
||||||
const expand = !raw.endsWith("/")
|
|
||||||
if (!expand || !tail) {
|
|
||||||
const items = base ? Array.from(new Set([base, ...deduped])) : deduped
|
|
||||||
return items.slice(0, 50)
|
|
||||||
}
|
|
||||||
|
|
||||||
const needle = tail.toLowerCase()
|
|
||||||
const exact = deduped.filter((p) => getFilename(p).toLowerCase() === needle)
|
|
||||||
const target = exact[0]
|
|
||||||
if (!target) return deduped.slice(0, 50)
|
|
||||||
|
|
||||||
const children = await match(target, "", 30)
|
|
||||||
if (!active()) return []
|
|
||||||
const items = Array.from(new Set([...deduped, ...children]))
|
|
||||||
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const { sync, sdk, ...serverCtx } = global.createServerCtx(props.server)
|
const { sync, sdk, ...serverCtx } = global.ensureServerCtx(props.server)
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
@@ -268,10 +73,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
|
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
|
||||||
)
|
)
|
||||||
|
|
||||||
const directories = useDirectorySearch({
|
const directories = createDirectorySearch({
|
||||||
sdk,
|
sdk,
|
||||||
home,
|
home,
|
||||||
start,
|
base: start,
|
||||||
})
|
})
|
||||||
|
|
||||||
const recentProjects = createMemo(() => {
|
const recentProjects = createMemo(() => {
|
||||||
@@ -336,7 +141,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open")
|
group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open")
|
||||||
}
|
}
|
||||||
ref={(r) => (list = r)}
|
ref={(r) => (list = r)}
|
||||||
onFilter={(value) => setFilter(cleanInput(value))}
|
onFilter={(value) => setFilter(cleanPickerInput(value))}
|
||||||
onKeyEvent={(e, item) => {
|
onKeyEvent={(e, item) => {
|
||||||
if (e.key !== "Tab") return
|
if (e.key !== "Tab") return
|
||||||
if (e.shiftKey) return
|
if (e.shiftKey) return
|
||||||
@@ -345,7 +150,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
const value = displayPath(item.absolute, filter(), home())
|
const value = displayPickerPath(item.absolute, filter(), home())
|
||||||
list?.setFilter(value.endsWith("/") ? value : value + "/")
|
list?.setFilter(value.endsWith("/") ? value : value + "/")
|
||||||
}}
|
}}
|
||||||
onSelect={(path) => {
|
onSelect={(path) => {
|
||||||
@@ -354,7 +159,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const path = displayPath(item.absolute, filter(), home())
|
const path = displayPickerPath(item.absolute, filter(), home())
|
||||||
if (path === "~") {
|
if (path === "~") {
|
||||||
return (
|
return (
|
||||||
<div class="w-full flex items-center justify-between rounded-md">
|
<div class="w-full flex items-center justify-between rounded-md">
|
||||||
|
|||||||
@@ -7,18 +7,25 @@ import { List } from "@opencode-ai/ui/list"
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
|
import { createMemo, createSignal, lazy, Match, onCleanup, Show, Switch } from "solid-js"
|
||||||
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
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 { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { getRelativeTime } from "@/utils/time"
|
import { getRelativeTime } from "@/utils/time"
|
||||||
|
|
||||||
|
const DialogSelectFileV2 = lazy(() =>
|
||||||
|
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
|
||||||
|
)
|
||||||
|
|
||||||
type EntryType = "command" | "file" | "session"
|
type EntryType = "command" | "file" | "session"
|
||||||
|
|
||||||
type Entry = {
|
type Entry = {
|
||||||
@@ -175,7 +182,7 @@ function createFileEntries(props: {
|
|||||||
function createSessionEntries(props: {
|
function createSessionEntries(props: {
|
||||||
workspaces: () => string[]
|
workspaces: () => string[]
|
||||||
label: (directory: string) => string
|
label: (directory: string) => string
|
||||||
serverSDK: ReturnType<typeof useServerSDK>
|
serverSDK: ServerSDK
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
}) {
|
}) {
|
||||||
const state: {
|
const state: {
|
||||||
@@ -264,6 +271,9 @@ function createSessionEntries(props: {
|
|||||||
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const server = useServer()
|
||||||
|
const settings = useSettings()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@@ -292,21 +302,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
if (directory && !dirs.includes(directory)) return [...dirs, directory]
|
if (directory && !dirs.includes(directory)) return [...dirs, directory]
|
||||||
return dirs
|
return dirs
|
||||||
})
|
})
|
||||||
const homedir = createMemo(() => serverSync.data.path.home)
|
const homedir = createMemo(() => serverSync().data.path.home)
|
||||||
const label = (directory: string) => {
|
const label = (directory: string) => {
|
||||||
const current = project()
|
const current = project()
|
||||||
const kind =
|
const kind =
|
||||||
current && directory === current.worktree
|
current && directory === current.worktree
|
||||||
? language.t("workspace.type.local")
|
? language.t("workspace.type.local")
|
||||||
: language.t("workspace.type.sandbox")
|
: language.t("workspace.type.sandbox")
|
||||||
const [store] = serverSync.child(directory, { bootstrap: false })
|
const [store] = serverSync().child(directory, { bootstrap: false })
|
||||||
const home = homedir()
|
const home = homedir()
|
||||||
const path = home ? directory.replace(home, "~") : directory
|
const path = home ? directory.replace(home, "~") : directory
|
||||||
const name = store.vcs?.branch ?? getFilename(directory)
|
const name = store.vcs?.branch ?? getFilename(directory)
|
||||||
return `${kind} : ${name || path}`
|
return `${kind} : ${name || path}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sessions } = createSessionEntries({ workspaces, label, serverSDK, language })
|
const { sessions } = createSessionEntries({ workspaces, label, serverSDK: serverSDK(), language })
|
||||||
|
|
||||||
const items = async (text: string) => {
|
const items = async (text: string) => {
|
||||||
const query = text.trim()
|
const query = text.trim()
|
||||||
@@ -383,6 +393,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
state.cleanup?.()
|
state.cleanup?.()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns() && server.current) {
|
||||||
|
return (
|
||||||
|
<DialogSelectFileV2
|
||||||
|
server={server.current}
|
||||||
|
mode="file"
|
||||||
|
start={projectDirectory()}
|
||||||
|
title={language.t("session.header.searchFiles")}
|
||||||
|
onSelect={(result) => {
|
||||||
|
if (typeof result !== "string") return
|
||||||
|
open(result)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
|
<Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
|
||||||
<List
|
<List
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const items = createMemo(() =>
|
const items = createMemo(() =>
|
||||||
Object.entries(sync.data.mcp ?? {})
|
Object.entries(sync().data.mcp ?? {})
|
||||||
.map(([name, status]) => ({ name, status: status.status }))
|
.map(([name, status]) => ({ name, status: status.status }))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
)
|
)
|
||||||
@@ -48,7 +48,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => {
|
{(i) => {
|
||||||
const mcpStatus = () => sync.data.mcp[i.name]
|
const mcpStatus = () => sync().data.mcp[i.name]
|
||||||
const status = () => mcpStatus()?.status
|
const status = () => mcpStatus()?.status
|
||||||
const statusLabel = () => {
|
const statusLabel = () => {
|
||||||
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
|
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
|
||||||
|
|||||||
@@ -10,24 +10,27 @@ import { useLocal } from "@/context/local"
|
|||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { ModelTooltip } from "./model-tooltip"
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||||
|
|
||||||
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
|
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
|
||||||
const model = props.model ?? useLocal().model
|
const local = useLocal()
|
||||||
|
const model = props.model ?? local.model
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const providers = useProviders()
|
const directory = () => decode64(local.slug())
|
||||||
|
const providers = useProviders(directory)
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const connect = (provider: string) => {
|
const connect = (provider: string) => {
|
||||||
void import("./dialog-connect-provider").then((x) => {
|
void import("./dialog-connect-provider").then((x) => {
|
||||||
dialog.show(() => <x.DialogConnectProvider provider={provider} />)
|
dialog.show(() => <x.DialogConnectProvider provider={provider} directory={directory} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const all = () => {
|
const all = () => {
|
||||||
void import("./dialog-select-provider").then((x) => {
|
void import("./dialog-select-provider").then((x) => {
|
||||||
dialog.show(() => <x.DialogSelectProvider />)
|
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { List } from "@opencode-ai/ui/list"
|
|||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { ModelTooltip } from "./model-tooltip"
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||||
provider === "opencode" && (!cost || cost.input === 0)
|
provider === "opencode" && (!cost || cost.input === 0)
|
||||||
@@ -104,6 +105,8 @@ export function ModelSelectorPopover(props: {
|
|||||||
dismiss: null,
|
dismiss: null,
|
||||||
})
|
})
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const local = useLocal()
|
||||||
|
const directory = () => decode64(local.slug())
|
||||||
|
|
||||||
const close = (dismiss: Dismiss) => {
|
const close = (dismiss: Dismiss) => {
|
||||||
setStore("dismiss", dismiss)
|
setStore("dismiss", dismiss)
|
||||||
@@ -120,7 +123,7 @@ export function ModelSelectorPopover(props: {
|
|||||||
const handleConnectProvider = () => {
|
const handleConnectProvider = () => {
|
||||||
close("provider")
|
close("provider")
|
||||||
void import("./dialog-select-provider").then((x) => {
|
void import("./dialog-select-provider").then((x) => {
|
||||||
dialog.show(() => <x.DialogSelectProvider />)
|
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -199,10 +202,12 @@ export function ModelSelectorPopover(props: {
|
|||||||
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
|
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const local = useLocal()
|
||||||
|
const directory = () => decode64(local.slug())
|
||||||
|
|
||||||
const provider = () => {
|
const provider = () => {
|
||||||
void import("./dialog-select-provider").then((x) => {
|
void import("./dialog-select-provider").then((x) => {
|
||||||
dialog.show(() => <x.DialogSelectProvider />)
|
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, Show } from "solid-js"
|
import { type Accessor, Component, Show } from "solid-js"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
@@ -11,9 +11,9 @@ import { DialogCustomProvider } from "./dialog-custom-provider"
|
|||||||
|
|
||||||
const CUSTOM_ID = "_custom"
|
const CUSTOM_ID = "_custom"
|
||||||
|
|
||||||
export const DialogSelectProvider: Component = () => {
|
export const DialogSelectProvider: Component<{ directory?: Accessor<string | undefined> }> = (props) => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const providers = useProviders()
|
const providers = useProviders(props.directory)
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||||
@@ -56,10 +56,10 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (!x) return
|
if (!x) return
|
||||||
if (x.id === CUSTOM_ID) {
|
if (x.id === CUSTOM_ID) {
|
||||||
dialog.show(() => <DialogCustomProvider back="providers" />)
|
dialog.show(() => <DialogCustomProvider back="providers" directory={props.directory} />)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dialog.show(() => <DialogConnectProvider provider={x.id} />)
|
dialog.show(() => <DialogConnectProvider provider={x.id} directory={props.directory} />)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import {
|
||||||
|
absoluteTreePath,
|
||||||
|
activeTreeNavigation,
|
||||||
|
advanceTreePreload,
|
||||||
|
nextSuggestionIndex,
|
||||||
|
nextTreeScrollTop,
|
||||||
|
pickerTreeEntries,
|
||||||
|
pickerSearchEntries,
|
||||||
|
pickerFileSearchQuery,
|
||||||
|
pickerMode,
|
||||||
|
preloadTreeDirectories,
|
||||||
|
selectedTreePath,
|
||||||
|
treeEntries,
|
||||||
|
treePathWithin,
|
||||||
|
currentPickerSuggestions,
|
||||||
|
createDirectorySearch,
|
||||||
|
createPriorityTaskQueue,
|
||||||
|
displayPickerPath,
|
||||||
|
pickerParent,
|
||||||
|
pickerRoot,
|
||||||
|
pickerAbsoluteInput,
|
||||||
|
} from "./directory-picker-domain"
|
||||||
|
|
||||||
|
test("maps server directory entries into Pierre paths", () => {
|
||||||
|
expect(
|
||||||
|
treeEntries("src/", [
|
||||||
|
{ name: "components", type: "directory" },
|
||||||
|
{ name: "index.ts", type: "file" },
|
||||||
|
]),
|
||||||
|
).toEqual(["src/components/", "src/index.ts"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("maps Pierre paths back to the selected server root", () => {
|
||||||
|
expect(absoluteTreePath("C:/Users/luke", "src/components/")).toBe("C:/Users/luke/src/components")
|
||||||
|
expect(absoluteTreePath("C:/", "")).toBe("C:/")
|
||||||
|
expect(absoluteTreePath("C:/", "README.md")).toBe("C:/README.md")
|
||||||
|
expect(absoluteTreePath("/home/luke", "README.md")).toBe("/home/luke/README.md")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("includes files only when the picker selects files", () => {
|
||||||
|
const nodes = [
|
||||||
|
{ name: "components", type: "directory" as const },
|
||||||
|
{ name: "index.ts", type: "file" as const },
|
||||||
|
]
|
||||||
|
expect(pickerTreeEntries("", nodes, "directory")).toEqual(["components/"])
|
||||||
|
expect(pickerTreeEntries("", nodes, "file")).toEqual(["components/", "index.ts"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("includes files in file autocomplete while preserving directory navigation", () => {
|
||||||
|
const nodes = [
|
||||||
|
{ name: "src", absolute: "/repo/src", type: "directory" as const },
|
||||||
|
{ name: "README.md", absolute: "/repo/README.md", type: "file" as const },
|
||||||
|
]
|
||||||
|
expect(pickerSearchEntries(nodes, "directory")).toEqual([nodes[0]])
|
||||||
|
expect(pickerSearchEntries(nodes, "file")).toEqual(nodes)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("centralizes file and directory selection policy", () => {
|
||||||
|
const file = pickerMode("file", "/repo")
|
||||||
|
expect(file.includeFiles).toBeTrue()
|
||||||
|
expect(file.selection("/repo/src", "index.ts")).toBe("src/index.ts")
|
||||||
|
expect(file.selection("/repo", "src/")).toBeUndefined()
|
||||||
|
expect(file.result("/repo", "src/index.ts")).toBe("src/index.ts")
|
||||||
|
expect(file.selection("/tmp", "example.txt")).toBeUndefined()
|
||||||
|
expect(file.navigation("/repo/src")).toBe("/repo/src")
|
||||||
|
expect(file.navigation("/tmp")).toBeUndefined()
|
||||||
|
|
||||||
|
const directory = pickerMode("directory")
|
||||||
|
expect(directory.includeFiles).toBeFalse()
|
||||||
|
expect(directory.selection("/repo", "src/")).toBe("/repo/src")
|
||||||
|
expect(directory.selection("C:/Users/luke", "repos/")).toBe("C:\\Users\\luke\\repos")
|
||||||
|
expect(directory.selection("//Server/Share", "repo/")).toBe("\\\\Server\\Share\\repo")
|
||||||
|
expect(directory.navigation("/tmp")).toBe("/tmp")
|
||||||
|
expect(directory.result("/repo", "")).toBe("/repo")
|
||||||
|
expect(directory.result("C:/Users/luke", "")).toBe("C:\\Users\\luke")
|
||||||
|
expect(directory.result("//Server/Share/repo", "")).toBe("\\\\Server\\Share\\repo")
|
||||||
|
expect(directory.result("/repo", "", false)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("accepts mutations only from the active navigation", () => {
|
||||||
|
expect(activeTreeNavigation(3, 3)).toBeTrue()
|
||||||
|
expect(activeTreeNavigation(2, 3)).toBeFalse()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves POSIX case while matching Windows drives case-insensitively", () => {
|
||||||
|
expect(treePathWithin("/repo", "/Repo")).toBeFalse()
|
||||||
|
expect(treePathWithin("C:/Repo", "c:/repo/src")).toBeTrue()
|
||||||
|
expect(treePathWithin("//Server/Share/Repo", "//server/share/repo/src")).toBeTrue()
|
||||||
|
expect(pickerMode("file", "//Server/Share/Repo").selection("//server/share/repo/src", "file.ts")).toBe("src/file.ts")
|
||||||
|
expect(treePathWithin("/repo", "/repo/../tmp")).toBeFalse()
|
||||||
|
expect(treePathWithin("/", "/src")).toBeTrue()
|
||||||
|
expect(pickerMode("file", "C:/Repo").selection("c:/repo/src", "file.ts")).toBe("src/file.ts")
|
||||||
|
expect(pickerMode("file", "C:/").selection("C:/", "file.ts")).toBe("file.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("displays paths using the selected server path format", () => {
|
||||||
|
expect(displayPickerPath("C:/Users/luke/repos", "C:/Users/luke/repos", "C:/Users/luke")).toBe(
|
||||||
|
"C:\\Users\\luke\\repos",
|
||||||
|
)
|
||||||
|
expect(displayPickerPath("C:/Users/luke/repos", "C:\\Users\\luke\\repos", "C:/Users/luke")).toBe(
|
||||||
|
"C:\\Users\\luke\\repos",
|
||||||
|
)
|
||||||
|
expect(displayPickerPath("/home/luke/repos", "repos", "/home/luke")).toBe("~/repos")
|
||||||
|
expect(displayPickerPath("/home/luke/repos", "~/repos", "/home/luke")).toBe("~/repos")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("treats the server share prefix as the UNC root", () => {
|
||||||
|
expect(pickerRoot("//Server/Share/repo/src")).toBe("//Server/Share")
|
||||||
|
expect(pickerRoot("\\\\Server\\Share\\repo\\src")).toBe("//Server/Share")
|
||||||
|
expect(pickerParent("//Server/Share")).toBe("//Server/Share")
|
||||||
|
expect(pickerParent("//Server/Share/repo")).toBe("//Server/Share")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolves relative input against the current picker root", () => {
|
||||||
|
expect(pickerAbsoluteInput("src", "/home/luke", "/home/luke/repo")).toBe("/home/luke/repo/src")
|
||||||
|
expect(pickerAbsoluteInput("../other", "/home/luke", "/home/luke/repo")).toBe("/home/luke/other")
|
||||||
|
expect(pickerAbsoluteInput("~/.config", "/home/luke", "/home/luke/repo")).toBe("/home/luke/.config")
|
||||||
|
expect(pickerAbsoluteInput("src", "C:/Users/luke", "C:/Users/luke/repo")).toBe("C:/Users/luke/repo/src")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("exposes autocomplete results only for their source query", () => {
|
||||||
|
const result = { query: "/repo/src", items: ["/repo/src/index.ts"] }
|
||||||
|
expect(currentPickerSuggestions(result, "/repo/src")).toEqual(result.items)
|
||||||
|
expect(currentPickerSuggestions(result, "/repo/test")).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("scopes file autocomplete to the current browser root", () => {
|
||||||
|
expect(pickerFileSearchQuery("/home/luke/repos", "/home/luke/repos/src/in", "/home/luke")).toBe("src/in")
|
||||||
|
expect(pickerFileSearchQuery("/home/luke", "~/repos/op", "/home/luke")).toBe("repos/op")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolves directory autocomplete from the current browser root", async () => {
|
||||||
|
const directories: string[] = []
|
||||||
|
const sdk = {
|
||||||
|
client: {
|
||||||
|
find: {
|
||||||
|
files: (input: { directory: string }) => {
|
||||||
|
directories.push(input.directory)
|
||||||
|
return Promise.resolve({ data: [] })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||||
|
let base = "/repo"
|
||||||
|
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base })
|
||||||
|
|
||||||
|
await search("components")
|
||||||
|
base = "/repo/src"
|
||||||
|
await search("components")
|
||||||
|
|
||||||
|
expect(directories).toEqual(["/repo", "/repo/src"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("identifies the next directory level to preload", () => {
|
||||||
|
expect(
|
||||||
|
preloadTreeDirectories("src/", [
|
||||||
|
{ name: "components", type: "directory" },
|
||||||
|
{ name: "index.ts", type: "file" },
|
||||||
|
{ name: "utils", type: "directory" },
|
||||||
|
]),
|
||||||
|
).toEqual(["src/components/", "src/utils/"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("advances preloading once for every expanded directory", () => {
|
||||||
|
const advanced = new Set<string>()
|
||||||
|
expect(advanceTreePreload(advanced, "")).toBeTrue()
|
||||||
|
expect(advanceTreePreload(advanced, "")).toBeFalse()
|
||||||
|
expect(advanceTreePreload(advanced, "repos/")).toBeTrue()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("limits background tasks and prioritizes newly requested work", async () => {
|
||||||
|
const queue = createPriorityTaskQueue<void>(2)
|
||||||
|
const first = Promise.withResolvers<void>()
|
||||||
|
const second = Promise.withResolvers<void>()
|
||||||
|
const started: string[] = []
|
||||||
|
let active = 0
|
||||||
|
let maximum = 0
|
||||||
|
const task = (name: string, blocker?: Promise<void>) => async () => {
|
||||||
|
started.push(name)
|
||||||
|
active++
|
||||||
|
maximum = Math.max(maximum, active)
|
||||||
|
await blocker
|
||||||
|
active--
|
||||||
|
}
|
||||||
|
|
||||||
|
const running = [
|
||||||
|
queue.schedule("first", "background", task("first", first.promise)),
|
||||||
|
queue.schedule("second", "background", task("second", second.promise)),
|
||||||
|
queue.schedule("preload", "background", task("preload")),
|
||||||
|
queue.schedule("opened", "user", task("opened")),
|
||||||
|
]
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(started).toEqual(["first", "second"])
|
||||||
|
|
||||||
|
first.resolve()
|
||||||
|
await running[0]
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(started).toEqual(["first", "second", "opened"])
|
||||||
|
|
||||||
|
second.resolve()
|
||||||
|
await Promise.all(running)
|
||||||
|
expect(started).toEqual(["first", "second", "opened", "preload"])
|
||||||
|
expect(maximum).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clamps bridged tree wheel scrolling", () => {
|
||||||
|
expect(nextTreeScrollTop(100, 40, 500, 200)).toBe(140)
|
||||||
|
expect(nextTreeScrollTop(10, -40, 500, 200)).toBe(0)
|
||||||
|
expect(nextTreeScrollTop(290, 40, 500, 200)).toBe(300)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("wraps autocomplete keyboard navigation", () => {
|
||||||
|
expect(nextSuggestionIndex(-1, 1, 4)).toBe(0)
|
||||||
|
expect(nextSuggestionIndex(3, 1, 4)).toBe(0)
|
||||||
|
expect(nextSuggestionIndex(0, -1, 4)).toBe(3)
|
||||||
|
expect(nextSuggestionIndex(0, 1, 0)).toBe(-1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns absolute directories and relative files", () => {
|
||||||
|
expect(selectedTreePath("/home/luke/repo", "src/", "directory")).toBe("/home/luke/repo/src")
|
||||||
|
expect(selectedTreePath("/home/luke/repo", "src/index.ts", "file")).toBe("src/index.ts")
|
||||||
|
expect(selectedTreePath("/home/luke/repo/src", "index.ts", "file", "/home/luke/repo")).toBe("src/index.ts")
|
||||||
|
expect(selectedTreePath("/home/luke/repo", "src/", "file")).toBeUndefined()
|
||||||
|
})
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
export function treeEntries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) {
|
||||||
|
const prefix = parent.replace(/^\/+|\/+$/g, "")
|
||||||
|
return nodes.map((node) => {
|
||||||
|
const path = prefix ? `${prefix}/${node.name}` : node.name
|
||||||
|
return node.type === "directory" ? path + "/" : path
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickerTreeEntries(
|
||||||
|
parent: string,
|
||||||
|
nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>,
|
||||||
|
mode: "directory" | "file",
|
||||||
|
) {
|
||||||
|
return treeEntries(parent, mode === "directory" ? nodes.filter((node) => node.type === "directory") : nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickerSearchEntries<T extends { type: "file" | "directory" }>(
|
||||||
|
nodes: readonly T[],
|
||||||
|
mode: "directory" | "file",
|
||||||
|
) {
|
||||||
|
return mode === "directory" ? nodes.filter((node) => node.type === "directory") : [...nodes]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickerMode(mode: "directory" | "file", base?: string) {
|
||||||
|
if (mode === "file") {
|
||||||
|
return {
|
||||||
|
includeFiles: true,
|
||||||
|
action: "file" as const,
|
||||||
|
entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) {
|
||||||
|
return treeEntries(parent, nodes)
|
||||||
|
},
|
||||||
|
navigation(path: string) {
|
||||||
|
return treePathWithin(base, path) ? path : undefined
|
||||||
|
},
|
||||||
|
result(root: string, selected: string) {
|
||||||
|
return selected || undefined
|
||||||
|
},
|
||||||
|
selection(root: string, path: string) {
|
||||||
|
if (!treePathWithin(base, root)) return
|
||||||
|
return selectedTreePath(root, path, "file", base)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
includeFiles: false,
|
||||||
|
action: "directory" as const,
|
||||||
|
entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) {
|
||||||
|
return treeEntries(
|
||||||
|
parent,
|
||||||
|
nodes.filter((node) => node.type === "directory"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigation(path: string) {
|
||||||
|
return path
|
||||||
|
},
|
||||||
|
result(root: string, selected: string, valid = true) {
|
||||||
|
if (!valid) return
|
||||||
|
return selected || (root ? nativePickerPath(root) : undefined)
|
||||||
|
},
|
||||||
|
selection(root: string, path: string) {
|
||||||
|
return selectedTreePath(root, path, "directory")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickerFileSearchQuery(root: string, input: string, home: string) {
|
||||||
|
const value = input
|
||||||
|
.replace(/\\/g, "/")
|
||||||
|
.replace(/^~(?=\/|$)/, home)
|
||||||
|
.replace(/\/+$/, "")
|
||||||
|
const base = root.replace(/\\/g, "/").replace(/\/+$/, "")
|
||||||
|
if (value === base) return ""
|
||||||
|
if (value.startsWith(base + "/")) return value.slice(base.length + 1)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickerAbsoluteInput(input: string, home: string, current: string) {
|
||||||
|
const value = normalizePickerDrive(input).replace(/^~(?=\/|$)/, normalizePickerDrive(home))
|
||||||
|
const absolute = pickerRoot(value) ? value : joinPickerPath(current, value)
|
||||||
|
return canonicalPickerPath(absolute)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function treePathWithin(base: string | undefined, path: string) {
|
||||||
|
return pickerRelativePath(base, path) !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalPickerPath(path: string) {
|
||||||
|
const value = normalizePickerDrive(path)
|
||||||
|
const root = pickerRoot(value)
|
||||||
|
const parts = value.slice(root.length).split("/")
|
||||||
|
const resolved = parts.reduce<string[]>((output, part) => {
|
||||||
|
if (!part || part === ".") return output
|
||||||
|
if (part === "..") {
|
||||||
|
output.pop()
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
output.push(part)
|
||||||
|
return output
|
||||||
|
}, [])
|
||||||
|
return joinPickerPath(root, resolved.join("/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickerRelativePath(base: string | undefined, path: string) {
|
||||||
|
if (!base) return
|
||||||
|
const rootPath = canonicalPickerPath(base)
|
||||||
|
const targetPath = canonicalPickerPath(path)
|
||||||
|
const insensitive = /^[A-Za-z]:\//.test(rootPath) || rootPath.startsWith("//")
|
||||||
|
const root = insensitive ? rootPath.toLowerCase() : rootPath
|
||||||
|
const target = insensitive ? targetPath.toLowerCase() : targetPath
|
||||||
|
if (target === root) return ""
|
||||||
|
const prefix = root.endsWith("/") ? root : root + "/"
|
||||||
|
if (!target.startsWith(prefix)) return
|
||||||
|
return targetPath.slice(prefix.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentPickerSuggestions<T>(result: { query: string; items: readonly T[] } | undefined, query: string) {
|
||||||
|
if (result?.query !== query) return []
|
||||||
|
return result.items
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preloadTreeDirectories(
|
||||||
|
parent: string,
|
||||||
|
nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>,
|
||||||
|
) {
|
||||||
|
return treeEntries(
|
||||||
|
parent,
|
||||||
|
nodes.filter((node) => node.type === "directory"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function advanceTreePreload(advanced: Set<string>, path: string) {
|
||||||
|
if (advanced.has(path)) return false
|
||||||
|
advanced.add(path)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activeTreeNavigation(request: number, current: number) {
|
||||||
|
return request === current
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPriorityTaskQueue<T>(concurrency: number) {
|
||||||
|
type Job = {
|
||||||
|
key: string
|
||||||
|
priority: "user" | "background"
|
||||||
|
promise: Promise<T>
|
||||||
|
run: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobs = new Map<string, Job>()
|
||||||
|
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<T>) => {
|
||||||
|
const existing = jobs.get(key)
|
||||||
|
if (existing) {
|
||||||
|
if (priority === "user") promote(key)
|
||||||
|
return existing.promise
|
||||||
|
}
|
||||||
|
|
||||||
|
const deferred = Promise.withResolvers<T>()
|
||||||
|
const job: Job = {
|
||||||
|
key,
|
||||||
|
priority,
|
||||||
|
promise: deferred.promise,
|
||||||
|
run: () => {
|
||||||
|
const complete = () => {
|
||||||
|
active--
|
||||||
|
jobs.delete(key)
|
||||||
|
drain()
|
||||||
|
}
|
||||||
|
Promise.resolve()
|
||||||
|
.then(task)
|
||||||
|
.then(
|
||||||
|
(value) => {
|
||||||
|
complete()
|
||||||
|
deferred.resolve(value)
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
complete()
|
||||||
|
deferred.reject(error)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
jobs.set(key, job)
|
||||||
|
;(priority === "user" ? user : background).push(job)
|
||||||
|
drain()
|
||||||
|
return job.promise
|
||||||
|
}
|
||||||
|
|
||||||
|
const promote = (key: string) => {
|
||||||
|
const job = jobs.get(key)
|
||||||
|
if (!job || job.priority === "user") return
|
||||||
|
const index = background.indexOf(job)
|
||||||
|
if (index === -1) return
|
||||||
|
background.splice(index, 1)
|
||||||
|
job.priority = "user"
|
||||||
|
user.push(job)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { schedule, promote }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextTreeScrollTop(current: number, delta: number, scrollHeight: number, clientHeight: number) {
|
||||||
|
return Math.min(Math.max(0, scrollHeight - clientHeight), Math.max(0, current + delta))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextSuggestionIndex(current: number, delta: -1 | 1, count: number) {
|
||||||
|
if (count === 0) return -1
|
||||||
|
return (current + delta + count) % count
|
||||||
|
}
|
||||||
|
|
||||||
|
export function absoluteTreePath(root: string, path: string) {
|
||||||
|
const base = trimPickerPath(root)
|
||||||
|
const relative = path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")
|
||||||
|
if (!relative) return base || "/"
|
||||||
|
if (!base || base === "/") return "/" + relative
|
||||||
|
if (base.endsWith("/")) return base + relative
|
||||||
|
return `${base}/${relative}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectedTreePath(root: string, path: string, mode: "directory" | "file", base?: string) {
|
||||||
|
const directory = path.endsWith("/")
|
||||||
|
if (mode === "file") {
|
||||||
|
if (directory) return
|
||||||
|
if (!base) return path
|
||||||
|
const absolute = absoluteTreePath(root, path)
|
||||||
|
return pickerRelativePath(base, absolute)
|
||||||
|
}
|
||||||
|
return directory ? nativePickerPath(absoluteTreePath(root, path)) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nativePickerPath(path: string) {
|
||||||
|
const value = trimPickerPath(path)
|
||||||
|
if (/^[A-Za-z]:\//.test(value) || value.startsWith("//")) return value.replaceAll("/", "\\")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
import fuzzysort from "fuzzysort"
|
||||||
|
import { ServerSDK } from "@/context/server-sdk"
|
||||||
|
|
||||||
|
export function cleanPickerInput(value: string) {
|
||||||
|
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
||||||
|
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePickerPath(input: string) {
|
||||||
|
const value = input.replaceAll("\\", "/")
|
||||||
|
if (value.startsWith("//") && !value.startsWith("///")) return "//" + value.slice(2).replace(/\/+/g, "/")
|
||||||
|
return value.replace(/\/+/g, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePickerDrive(input: string) {
|
||||||
|
const value = normalizePickerPath(input)
|
||||||
|
if (/^[A-Za-z]:$/.test(value)) return value + "/"
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trimPickerPath(input: string) {
|
||||||
|
const value = normalizePickerDrive(input)
|
||||||
|
if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value
|
||||||
|
return value.replace(/\/+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinPickerPath(base: string | undefined, relative: string) {
|
||||||
|
const root = trimPickerPath(base ?? "")
|
||||||
|
const path = trimPickerPath(relative).replace(/^\/+/, "")
|
||||||
|
if (!root) return path
|
||||||
|
if (!path) return root
|
||||||
|
if (root.endsWith("/")) return root + path
|
||||||
|
return root + "/" + path
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickerRoot(input: string) {
|
||||||
|
const value = normalizePickerDrive(input)
|
||||||
|
if (value.startsWith("//")) {
|
||||||
|
const [server, share] = value.slice(2).split("/")
|
||||||
|
if (server && share) return `//${server}/${share}`
|
||||||
|
return "//"
|
||||||
|
}
|
||||||
|
if (value.startsWith("/")) return "/"
|
||||||
|
if (/^[A-Za-z]:\//.test(value)) return value.slice(0, 3)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickerParent(input: string) {
|
||||||
|
const value = trimPickerPath(input)
|
||||||
|
const root = pickerRoot(value)
|
||||||
|
if (value === root) return value
|
||||||
|
if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value
|
||||||
|
const index = value.lastIndexOf("/")
|
||||||
|
if (index < root.length) return root
|
||||||
|
if (index <= 0) return "/"
|
||||||
|
if (index === 2 && /^[A-Za-z]:/.test(value)) return value.slice(0, 3)
|
||||||
|
return value.slice(0, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickerTilde(absolute: string, home: string) {
|
||||||
|
const path = trimPickerPath(absolute)
|
||||||
|
if (!home) return ""
|
||||||
|
const root = trimPickerPath(home)
|
||||||
|
if (/^[A-Za-z]:\//.test(root)) return ""
|
||||||
|
if (path === root) return "~"
|
||||||
|
if (path.startsWith(root + "/")) return "~" + path.slice(root.length)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayPickerPath(path: string, input: string, home: string) {
|
||||||
|
const value = trimPickerPath(path)
|
||||||
|
if (/^[A-Za-z]:\//.test(trimPickerPath(home)) || /^[A-Za-z]:\//.test(value)) return value.replaceAll("/", "\\")
|
||||||
|
return pickerTilde(value, home) || value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) {
|
||||||
|
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
||||||
|
let current = 0
|
||||||
|
|
||||||
|
const scoped = (value: string) => {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const directories = async (directory: string) => {
|
||||||
|
const key = trimPickerPath(directory)
|
||||||
|
const existing = cache.get(key)
|
||||||
|
if (existing) return existing
|
||||||
|
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) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })),
|
||||||
|
)
|
||||||
|
cache.set(key, request)
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = async (directory: string, query: string, limit: number) => {
|
||||||
|
const items = await directories(directory)
|
||||||
|
if (!query) return items.slice(0, limit).map((item) => item.absolute)
|
||||||
|
return fuzzysort.go(query, items, { key: "name", limit }).map((item) => item.obj.absolute)
|
||||||
|
}
|
||||||
|
|
||||||
|
return async (filter: string) => {
|
||||||
|
const token = ++current
|
||||||
|
const active = () => token === current
|
||||||
|
const value = cleanPickerInput(filter)
|
||||||
|
const input = scoped(value)
|
||||||
|
if (!input) return [] as string[]
|
||||||
|
const raw = normalizePickerDrive(value)
|
||||||
|
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
||||||
|
const query = normalizePickerDrive(input.path)
|
||||||
|
if (!pathInput) {
|
||||||
|
const results = await args.sdk.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)
|
||||||
|
}
|
||||||
|
const segments = query.replace(/^\/+/, "").split("/")
|
||||||
|
const head = segments.slice(0, -1).filter((part) => part && part !== ".")
|
||||||
|
const tail = segments.at(-1) ?? ""
|
||||||
|
let paths = [input.directory]
|
||||||
|
for (const part of head) {
|
||||||
|
if (!active()) return []
|
||||||
|
if (part === "..") {
|
||||||
|
paths = paths.map(pickerParent)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
paths = Array.from(new Set((await Promise.all(paths.map((path) => match(path, part, 4)))).flat())).slice(0, 12)
|
||||||
|
if (!active() || paths.length === 0) return []
|
||||||
|
}
|
||||||
|
const matches = Array.from(new Set((await Promise.all(paths.map((path) => match(path, tail, 50)))).flat()))
|
||||||
|
if (!active()) return []
|
||||||
|
const base = raw.startsWith("~") ? trimPickerPath(input.directory) : ""
|
||||||
|
if (raw.endsWith("/") || !tail) return Array.from(new Set([base, ...matches].filter(Boolean))).slice(0, 50)
|
||||||
|
const target = matches.find((path) => getFilename(path).toLowerCase() === tail.toLowerCase())
|
||||||
|
if (!target) return matches.slice(0, 50)
|
||||||
|
const children = await match(target, "", 30)
|
||||||
|
if (!active()) return []
|
||||||
|
return Array.from(new Set([base, ...matches, ...children].filter(Boolean))).slice(0, 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
|
import { lazy } from "solid-js"
|
||||||
import { DialogSelectDirectory } from "./dialog-select-directory"
|
import { DialogSelectDirectory } from "./dialog-select-directory"
|
||||||
import { directoryPickerKind } from "./directory-picker-policy"
|
import { directoryPickerKind } from "./directory-picker-policy"
|
||||||
|
|
||||||
|
const DialogSelectDirectoryV2 = lazy(() =>
|
||||||
|
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
|
||||||
|
)
|
||||||
|
|
||||||
type DirectoryPickerInput = {
|
type DirectoryPickerInput = {
|
||||||
server: ServerConnection.Any
|
server: ServerConnection.Any
|
||||||
title?: string
|
title?: string
|
||||||
@@ -13,6 +19,7 @@ type DirectoryPickerInput = {
|
|||||||
|
|
||||||
export function useDirectoryPicker() {
|
export function useDirectoryPicker() {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const settings = useSettings()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
||||||
return (input: DirectoryPickerInput) => {
|
return (input: DirectoryPickerInput) => {
|
||||||
@@ -21,9 +28,18 @@ export function useDirectoryPicker() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog.show(
|
let selected = false
|
||||||
() => <DialogSelectDirectory {...input} />,
|
const onSelect = (result: string | string[] | null) => {
|
||||||
() => input.onSelect(null),
|
selected = result !== null
|
||||||
)
|
input.onSelect(result)
|
||||||
|
}
|
||||||
|
const cancel = () => {
|
||||||
|
if (!selected) input.onSelect(null)
|
||||||
|
}
|
||||||
|
if (platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
|
||||||
|
dialog.show(() => <DialogSelectDirectoryV2 {...input} onSelect={onSelect} />, cancel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dialog.show(() => <DialogSelectDirectory {...input} onSelect={onSelect} />, cancel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export function HelpButton() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={!state.dismissed}>
|
<Show when={!state.dismissed}>
|
||||||
<div class="fixed bottom-4 right-4 z-50">
|
<div class="fixed bottom-4 right-4 z-50 hidden md:block">
|
||||||
<Popover
|
<Popover
|
||||||
open={shown()}
|
open={shown()}
|
||||||
onOpenChange={setShown}
|
onOpenChange={setShown}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { FileTree, type FileTreeDirectoryHandle } from "@pierre/trees"
|
||||||
|
|
||||||
|
test("reports directory expansion changes", () => {
|
||||||
|
const changes: Array<{ path: string; expanded: boolean }> = []
|
||||||
|
const tree = new FileTree({
|
||||||
|
paths: ["src/"],
|
||||||
|
onExpansionChange: (change) => changes.push(change),
|
||||||
|
})
|
||||||
|
|
||||||
|
const src = tree.getItem("src/")
|
||||||
|
if (!src || !src.isDirectory()) throw new Error("Expected src to be a directory")
|
||||||
|
const directory = src as FileTreeDirectoryHandle
|
||||||
|
|
||||||
|
directory.expand()
|
||||||
|
directory.collapse()
|
||||||
|
|
||||||
|
expect(changes).toEqual([
|
||||||
|
{ path: "src/", expanded: true },
|
||||||
|
{ path: "src/", expanded: false },
|
||||||
|
])
|
||||||
|
tree.cleanUp()
|
||||||
|
})
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||||
|
import { createPromptState } from "@/context/prompt"
|
||||||
|
import { SessionComposerRegion } from "@/pages/session/composer"
|
||||||
|
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
||||||
|
|
||||||
|
function createPromptInputStoryRuntime() {
|
||||||
|
const state = createPromptState()
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
history: createPromptInputHistory(),
|
||||||
|
submission: {
|
||||||
|
abort() {},
|
||||||
|
handleSubmit(event: Event) {
|
||||||
|
event.preventDefault()
|
||||||
|
state.reset()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptInputExample() {
|
||||||
|
const input = createPromptInputStoryRuntime()
|
||||||
|
const [controls, setControls] = createStore({
|
||||||
|
agent: "build",
|
||||||
|
variant: undefined as string | undefined,
|
||||||
|
comments: 0,
|
||||||
|
tabs: [] as string[],
|
||||||
|
activeTab: undefined as string | undefined,
|
||||||
|
reviewOpen: false,
|
||||||
|
})
|
||||||
|
const model = {
|
||||||
|
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 inputControls = {
|
||||||
|
agents: {
|
||||||
|
available: [{ name: "review", hidden: false, mode: "subagent" }],
|
||||||
|
options: ["build", "review", "plan"],
|
||||||
|
get current() {
|
||||||
|
return controls.agent
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
visible: true,
|
||||||
|
select: (agent?: string) => setControls("agent", agent ?? "build"),
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
selection: model,
|
||||||
|
paid: true,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
projects: {
|
||||||
|
available: [{ name: "Story project", worktree: "/tmp/story", sandboxes: [] }],
|
||||||
|
directory: "/tmp/story",
|
||||||
|
select() {},
|
||||||
|
add() {},
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
id: "story-session",
|
||||||
|
tabs: {
|
||||||
|
active: () => controls.activeTab,
|
||||||
|
all: () => controls.tabs,
|
||||||
|
open: (tab: string) => setControls("tabs", (tabs) => (tabs.includes(tab) ? tabs : [...tabs, tab])),
|
||||||
|
setActive: (tab: string) => setControls("activeTab", tab),
|
||||||
|
},
|
||||||
|
reviewPanel: {
|
||||||
|
opened: () => controls.reviewOpen,
|
||||||
|
open: () => setControls("reviewOpen", true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
newLayoutDesigns: true,
|
||||||
|
}
|
||||||
|
const addReviewComment = () => {
|
||||||
|
const comment = controls.comments + 1
|
||||||
|
setControls("comments", comment)
|
||||||
|
input.state.context.add({
|
||||||
|
type: "file",
|
||||||
|
path: "src/components/prompt-input.tsx",
|
||||||
|
selection: {
|
||||||
|
startLine: 84 + comment,
|
||||||
|
startChar: 0,
|
||||||
|
endLine: 84 + comment,
|
||||||
|
endChar: 0,
|
||||||
|
},
|
||||||
|
comment: `Review comment ${comment}`,
|
||||||
|
commentID: `review-comment-${comment}`,
|
||||||
|
commentOrigin: "review",
|
||||||
|
preview: "export const PromptInput = ...",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<PromptInput controls={inputControls} {...input} />
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-border-weak-base bg-background-base px-2.5 py-1.5 text-12-medium text-text-base hover:bg-background-stronger"
|
||||||
|
onClick={addReviewComment}
|
||||||
|
>
|
||||||
|
Add review comment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const todos: Todo[] = [
|
||||||
|
{ id: "todo-1", content: "Inspect the session composer animation", status: "completed" },
|
||||||
|
{ id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" },
|
||||||
|
{ id: "todo-3", content: "Verify session navigation behavior", status: "pending" },
|
||||||
|
]
|
||||||
|
|
||||||
|
function PromptInputWithOpenDock() {
|
||||||
|
const input = createPromptInputStoryRuntime()
|
||||||
|
const [controls, setControls] = createStore({
|
||||||
|
agent: "build",
|
||||||
|
activeTab: undefined as string | undefined,
|
||||||
|
todoCollapsed: false,
|
||||||
|
})
|
||||||
|
const inputControls = {
|
||||||
|
agents: {
|
||||||
|
available: [],
|
||||||
|
options: ["build"],
|
||||||
|
get current() {
|
||||||
|
return controls.agent
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
visible: true,
|
||||||
|
select: (agent?: string) => setControls("agent", agent ?? "build"),
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
selection: {
|
||||||
|
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
||||||
|
variant: { list: () => [], current: () => undefined, set: () => {} },
|
||||||
|
},
|
||||||
|
paid: true,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
projects: { available: [], directory: "/tmp/story", select: () => {}, add: () => {} },
|
||||||
|
session: {
|
||||||
|
id: "story-session",
|
||||||
|
tabs: {
|
||||||
|
active: () => controls.activeTab,
|
||||||
|
all: () => [],
|
||||||
|
open: () => {},
|
||||||
|
setActive: (tab: string) => setControls("activeTab", tab),
|
||||||
|
},
|
||||||
|
reviewPanel: { opened: () => false, open: () => {} },
|
||||||
|
},
|
||||||
|
newLayoutDesigns: true,
|
||||||
|
}
|
||||||
|
const state = {
|
||||||
|
blocked: () => false,
|
||||||
|
questionRequest: () => undefined,
|
||||||
|
permissionRequest: () => undefined,
|
||||||
|
permissionResponding: () => false,
|
||||||
|
decide: () => {},
|
||||||
|
todos: () => todos,
|
||||||
|
dock: () => true,
|
||||||
|
closing: () => false,
|
||||||
|
opening: () => false,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionComposerRegion
|
||||||
|
state={state}
|
||||||
|
sessionKey="story-session"
|
||||||
|
sessionID="story-session"
|
||||||
|
controls={inputControls}
|
||||||
|
promptInput={{ ...input, ref: () => {}, newSessionWorktree: "", onNewSessionWorktreeReset: () => {} }}
|
||||||
|
todo={{
|
||||||
|
collapsed: controls.todoCollapsed,
|
||||||
|
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
|
||||||
|
}}
|
||||||
|
ready
|
||||||
|
centered={false}
|
||||||
|
onResponseSubmit={() => {}}
|
||||||
|
setPromptDockRef={() => {}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "App/PromptInput",
|
||||||
|
id: "app-prompt-input",
|
||||||
|
component: PromptInput,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Basic = {
|
||||||
|
render: () => (
|
||||||
|
<div class="pt-10">
|
||||||
|
<h1 class="mb-4">Prompt Input</h1>
|
||||||
|
<PromptInputExample />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DockAlreadyOpen = {
|
||||||
|
render: () => (
|
||||||
|
<div class="pt-10">
|
||||||
|
<h1 class="mb-4">Prompt Input with open Todo dock</h1>
|
||||||
|
<PromptInputWithOpenDock />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -17,8 +17,8 @@ import {
|
|||||||
type JSX,
|
type JSX,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import { useLocal } from "@/context/local"
|
import type { useLocal } from "@/context/local"
|
||||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||||
import {
|
import {
|
||||||
ContentPart,
|
ContentPart,
|
||||||
@@ -31,10 +31,7 @@ import {
|
|||||||
FileAttachmentPart,
|
FileAttachmentPart,
|
||||||
} from "@/context/prompt"
|
} from "@/context/prompt"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useServer } from "@/context/server"
|
|
||||||
import { useTabs } from "@/context/tabs"
|
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
@@ -46,14 +43,11 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
|||||||
import { Select } from "@opencode-ai/ui/select"
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSettings } from "@/context/settings"
|
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
||||||
import { createPromptAttachments } from "./prompt-input/attachments"
|
import { createPromptAttachments } from "./prompt-input/attachments"
|
||||||
@@ -73,18 +67,105 @@ import { PromptContextItems } from "./prompt-input/context-items"
|
|||||||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
||||||
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
||||||
import { promptPlaceholder } from "./prompt-input/placeholder"
|
import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||||
import { useDirectoryPicker } from "./directory-picker"
|
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||||
import { useQueries } from "@tanstack/solid-query"
|
|
||||||
import { useQueryOptions } from "@/context/server-sync"
|
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { displayName } from "@/pages/layout/helpers"
|
import { displayName } from "@/pages/layout/helpers"
|
||||||
|
|
||||||
interface PromptInputProps {
|
export type PromptInputState = ReturnType<typeof usePrompt>
|
||||||
|
|
||||||
|
export type PromptInputHistory = {
|
||||||
|
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
|
||||||
|
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromptInputSubmission = {
|
||||||
|
abort: () => Promise<void> | void
|
||||||
|
handleSubmit: (event: Event) => Promise<void> | 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<typeof useLocal>["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<void>
|
||||||
|
setActive: (tab: string) => void
|
||||||
|
}
|
||||||
|
reviewPanel: {
|
||||||
|
opened: () => boolean
|
||||||
|
open: () => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newLayoutDesigns: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPromptInputHistory(): PromptInputHistory {
|
||||||
|
const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
|
||||||
|
const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
|
||||||
|
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
|
||||||
|
|
||||||
|
function createPromptInputHistoryStore(
|
||||||
|
normal: Store<PromptHistoryState>,
|
||||||
|
setNormal: SetStoreFunction<PromptHistoryState>,
|
||||||
|
shell: Store<PromptHistoryState>,
|
||||||
|
setShell: SetStoreFunction<PromptHistoryState>,
|
||||||
|
): 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<PromptHistoryState>({ entries: [] }),
|
||||||
|
)
|
||||||
|
const [shell, setShell] = persisted(
|
||||||
|
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
|
||||||
|
createStore<PromptHistoryState>({ entries: [] }),
|
||||||
|
)
|
||||||
|
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptInputProps {
|
||||||
class?: string
|
class?: string
|
||||||
variant?: "dock" | "new-session"
|
variant?: "dock" | "new-session"
|
||||||
|
state?: PromptInputState
|
||||||
|
history?: PromptInputHistory
|
||||||
|
submission?: PromptInputSubmission
|
||||||
|
controls: PromptInputControls
|
||||||
ref?: (el: HTMLDivElement) => void
|
ref?: (el: HTMLDivElement) => void
|
||||||
newSessionWorktree?: string
|
newSessionWorktree?: string
|
||||||
onNewSessionWorktreeReset?: () => void
|
onNewSessionWorktreeReset?: () => void
|
||||||
@@ -126,27 +207,18 @@ const EXAMPLES = [
|
|||||||
|
|
||||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const navigate = useNavigate()
|
|
||||||
const queryOptions = useQueryOptions()
|
|
||||||
|
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const local = useLocal()
|
|
||||||
const files = useFile()
|
const files = useFile()
|
||||||
const prompt = usePrompt()
|
const prompt = props.state ?? usePrompt()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const server = useServer()
|
|
||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const providers = useProviders()
|
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const pickDirectory = useDirectoryPicker()
|
const tabs = () => props.controls.session.tabs
|
||||||
const settings = useSettings()
|
|
||||||
const tabsStore = useTabs()
|
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
|
||||||
const { params, tabs, view } = useSessionLayout()
|
|
||||||
let editorRef!: HTMLDivElement
|
let editorRef!: HTMLDivElement
|
||||||
let fileInputRef: HTMLInputElement | undefined
|
let fileInputRef: HTMLInputElement | undefined
|
||||||
let scrollRef!: HTMLDivElement
|
let scrollRef!: HTMLDivElement
|
||||||
@@ -204,10 +276,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}).activeFileTab
|
}).activeFileTab
|
||||||
|
|
||||||
const commentInReview = (path: string) => {
|
const commentInReview = (path: string) => {
|
||||||
const sessionID = params.id
|
const sessionID = props.controls.session.id
|
||||||
if (!sessionID) return false
|
if (!sessionID) return false
|
||||||
|
|
||||||
const diffs = sync.data.session_diff[sessionID]
|
const diffs = sync().data.session_diff[sessionID]
|
||||||
if (!diffs) return false
|
if (!diffs) return false
|
||||||
return diffs.some((diff) => diff.file === path)
|
return diffs.some((diff) => diff.file === path)
|
||||||
}
|
}
|
||||||
@@ -237,14 +309,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
|
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
|
||||||
if (wantsReview) {
|
if (wantsReview) {
|
||||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
|
||||||
layout.fileTree.setTab("changes")
|
layout.fileTree.setTab("changes")
|
||||||
tabs().setActive("review")
|
tabs().setActive("review")
|
||||||
queueCommentFocus()
|
queueCommentFocus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
|
||||||
layout.fileTree.setTab("all")
|
layout.fileTree.setTab("all")
|
||||||
const tab = files.tab(item.path)
|
const tab = files.tab(item.path)
|
||||||
void tabs().open(tab)
|
void tabs().open(tab)
|
||||||
@@ -269,31 +341,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
return paths
|
return paths
|
||||||
})
|
})
|
||||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
|
||||||
const working = createMemo(() => sync.data.session_working(params.id ?? ""))
|
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
|
||||||
const imageAttachments = createMemo(() =>
|
const imageAttachments = createMemo(() =>
|
||||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||||
)
|
)
|
||||||
|
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createPromptInputTransientState(
|
||||||
popover: "at" | "slash" | null
|
() => prompt.capture(),
|
||||||
historyIndex: number
|
Math.floor(Math.random() * EXAMPLES.length),
|
||||||
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({
|
const [picker, setPicker] = createStore({
|
||||||
projectOpen: false,
|
projectOpen: false,
|
||||||
projectSearch: "",
|
projectSearch: "",
|
||||||
@@ -347,29 +404,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const hasUserPrompt = createMemo(() => {
|
const hasUserPrompt = createMemo(() => {
|
||||||
const sessionID = params.id
|
const sessionID = props.controls.session.id
|
||||||
if (!sessionID) return false
|
if (!sessionID) return false
|
||||||
const messages = sync.data.message[sessionID]
|
const messages = sync().data.message[sessionID]
|
||||||
if (!messages) return false
|
if (!messages) return false
|
||||||
return messages.some((m) => m.role === "user")
|
return messages.some((m) => m.role === "user")
|
||||||
})
|
})
|
||||||
|
|
||||||
const [history, setHistory] = persisted(
|
const history = props.history ?? createPersistedPromptInputHistory()
|
||||||
Persist.global("prompt-history", ["prompt-history.v1"]),
|
|
||||||
createStore<{
|
|
||||||
entries: PromptHistoryStoredEntry[]
|
|
||||||
}>({
|
|
||||||
entries: [],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const [shellHistory, setShellHistory] = persisted(
|
|
||||||
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
|
|
||||||
createStore<{
|
|
||||||
entries: PromptHistoryStoredEntry[]
|
|
||||||
}>({
|
|
||||||
entries: [],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const suggest = createMemo(() => !hasUserPrompt())
|
const suggest = createMemo(() => !hasUserPrompt())
|
||||||
|
|
||||||
@@ -474,7 +516,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const pick = () => {
|
const pick = () => {
|
||||||
pickAttachmentFiles({
|
pickAttachmentFiles({
|
||||||
picker: platform.openAttachmentPickerDialog,
|
picker: platform.openAttachmentPickerDialog,
|
||||||
directory: () => sdk.directory,
|
directory: () => sdk().directory,
|
||||||
fallback: () => fileInputRef?.click(),
|
fallback: () => fileInputRef?.click(),
|
||||||
onFile: addAttachment,
|
onFile: addAttachment,
|
||||||
onError: (error) =>
|
onError: (error) =>
|
||||||
@@ -573,8 +615,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
params.id
|
props.controls.session.id
|
||||||
if (params.id) return
|
if (props.controls.session.id) return
|
||||||
if (!suggest()) return
|
if (!suggest()) return
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length)
|
setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length)
|
||||||
@@ -603,11 +645,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const agentList = createMemo(() =>
|
const agentList = createMemo(() =>
|
||||||
sync.data.agent
|
props.controls.agents.available
|
||||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||||
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
|
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
|
||||||
)
|
)
|
||||||
const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name))
|
|
||||||
|
|
||||||
const handleAtSelect = (option: AtOption | undefined) => {
|
const handleAtSelect = (option: AtOption | undefined) => {
|
||||||
if (!option) return
|
if (!option) return
|
||||||
@@ -673,7 +714,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
type: "builtin" as const,
|
type: "builtin" as const,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const custom = sync.data.command.map((cmd) => ({
|
const custom = sync().data.command.map((cmd) => ({
|
||||||
id: `custom.${cmd.name}`,
|
id: `custom.${cmd.name}`,
|
||||||
trigger: cmd.name,
|
trigger: cmd.name,
|
||||||
title: cmd.name,
|
title: cmd.name,
|
||||||
@@ -1033,11 +1074,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
||||||
const currentHistory = mode === "shell" ? shellHistory : history
|
history.add(prompt, mode, mode === "shell" ? [] : historyComments())
|
||||||
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
|
|
||||||
const next = prependHistoryEntry(currentHistory.entries, prompt, mode === "shell" ? [] : historyComments())
|
|
||||||
if (next === currentHistory.entries) return
|
|
||||||
setCurrentHistory("entries", next)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
@@ -1082,7 +1119,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const navigateHistory = (direction: "up" | "down") => {
|
const navigateHistory = (direction: "up" | "down") => {
|
||||||
const result = navigatePromptHistory({
|
const result = navigatePromptHistory({
|
||||||
direction,
|
direction,
|
||||||
entries: store.mode === "shell" ? shellHistory.entries : history.entries,
|
entries: history.entries(store.mode),
|
||||||
historyIndex: store.historyIndex,
|
historyIndex: store.historyIndex,
|
||||||
currentPrompt: prompt.current(),
|
currentPrompt: prompt.current(),
|
||||||
currentComments: historyComments(),
|
currentComments: historyComments(),
|
||||||
@@ -1096,6 +1133,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
||||||
|
prompt,
|
||||||
editor: () => editorRef,
|
editor: () => editorRef,
|
||||||
isDialogActive: () => !!dialog.active,
|
isDialogActive: () => !!dialog.active,
|
||||||
setDraggingType: (type) => setStore("draggingType", type),
|
setDraggingType: (type) => setStore("draggingType", type),
|
||||||
@@ -1105,6 +1143,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
},
|
},
|
||||||
addPart,
|
addPart,
|
||||||
readClipboardImage: platform.readClipboardImage,
|
readClipboardImage: platform.readClipboardImage,
|
||||||
|
getPathForFile: platform.getPathForFile,
|
||||||
})
|
})
|
||||||
|
|
||||||
const fileAttachmentInput = () => (
|
const fileAttachmentInput = () => (
|
||||||
@@ -1122,38 +1161,41 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
|
||||||
// Check provider variants directly: `variants` also includes the UI-only default option.
|
// Check provider variants directly: `variants` also includes the UI-only default option.
|
||||||
const showVariantControl = createMemo(() => local.model.variant.list().length > 0)
|
const showVariantControl = createMemo(() => props.controls.model.selection.variant.list().length > 0)
|
||||||
const accepting = createMemo(() => {
|
const accepting = createMemo(() => {
|
||||||
const id = params.id
|
const id = props.controls.session.id
|
||||||
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
|
if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
|
||||||
return permission.isAutoAccepting(id, sdk.directory)
|
return permission.isAutoAccepting(id, sdk().directory)
|
||||||
})
|
})
|
||||||
|
|
||||||
const { abort, handleSubmit } = createPromptSubmit({
|
const { abort, handleSubmit } =
|
||||||
info,
|
props.submission ??
|
||||||
imageAttachments,
|
createPromptSubmit({
|
||||||
commentCount,
|
prompt,
|
||||||
autoAccept: () => accepting(),
|
info,
|
||||||
mode: () => store.mode,
|
imageAttachments,
|
||||||
working,
|
commentCount,
|
||||||
editor: () => editorRef,
|
autoAccept: () => accepting(),
|
||||||
queueScroll,
|
mode: () => store.mode,
|
||||||
promptLength,
|
working,
|
||||||
addToHistory,
|
editor: () => editorRef,
|
||||||
resetHistoryNavigation: () => {
|
queueScroll,
|
||||||
resetHistoryNavigation(true)
|
promptLength,
|
||||||
},
|
addToHistory,
|
||||||
setMode: (mode) => setStore("mode", mode),
|
resetHistoryNavigation: () => {
|
||||||
setPopover: (popover) => setStore("popover", popover),
|
resetHistoryNavigation(true)
|
||||||
newSessionWorktree: () => props.newSessionWorktree,
|
},
|
||||||
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
setMode: (mode) => setStore("mode", mode),
|
||||||
shouldQueue: props.shouldQueue,
|
setPopover: (popover) => setStore("popover", popover),
|
||||||
onQueue: props.onQueue,
|
newSessionWorktree: () => props.newSessionWorktree,
|
||||||
onAbort: props.onAbort,
|
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||||
onSubmit: props.onSubmit,
|
shouldQueue: props.shouldQueue,
|
||||||
})
|
onQueue: props.onQueue,
|
||||||
|
onAbort: props.onAbort,
|
||||||
|
onSubmit: props.onSubmit,
|
||||||
|
})
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
|
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
|
||||||
@@ -1317,21 +1359,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({
|
const agentsLoading = () => props.controls.agents.loading
|
||||||
queries: [
|
|
||||||
queryOptions.agents(pathKey(sdk.directory)),
|
|
||||||
queryOptions.providers(null),
|
|
||||||
queryOptions.providers(pathKey(sdk.directory)),
|
|
||||||
],
|
|
||||||
}))
|
|
||||||
|
|
||||||
const agentsLoading = () => agentsQuery.isLoading
|
|
||||||
const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
|
const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
|
||||||
const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading
|
const providersLoading = () => props.controls.model.loading
|
||||||
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
|
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
|
||||||
|
|
||||||
const [promptReady] = createResource(
|
const [promptReady] = createResource(
|
||||||
() => prompt.ready().promise,
|
() => prompt.ready.promise,
|
||||||
(p) => p,
|
(p) => p,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1342,23 +1376,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const modelControlState = createMemo<ComposerModelControlState>(() => ({
|
const modelControlState = createMemo<ComposerModelControlState>(() => ({
|
||||||
loading: providersLoading(),
|
loading: providersLoading(),
|
||||||
paid: providers.paid().length > 0,
|
paid: props.controls.model.paid,
|
||||||
title: language.t("command.model.choose"),
|
title: language.t("command.model.choose"),
|
||||||
keybind: command.keybind("model.choose"),
|
keybind: command.keybind("model.choose"),
|
||||||
model: local.model,
|
model: props.controls.model.selection,
|
||||||
providerID: local.model.current()?.provider?.id,
|
providerID: props.controls.model.selection.current()?.provider?.id,
|
||||||
modelName: local.model.current()?.name ?? language.t("dialog.model.select.title"),
|
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
|
||||||
style: control(),
|
style: control(),
|
||||||
onClose: restoreFocus,
|
onClose: restoreFocus,
|
||||||
onUnpaidClick: () => {
|
onUnpaidClick: () => {
|
||||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||||
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
dialog.show(() => <x.DialogSelectModelUnpaid model={props.controls.model.selection} />)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const newSession = () => props.variant === "new-session"
|
const newSession = () => props.variant === "new-session"
|
||||||
const projects = createMemo(() => layout.projects.list())
|
const projects = createMemo(() => props.controls.projects.available)
|
||||||
const projectForDirectory = (directory: string | undefined) => {
|
const projectForDirectory = (directory: string | undefined) => {
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
const key = pathKey(directory)
|
const key = pathKey(directory)
|
||||||
@@ -1366,13 +1400,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
(project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
|
(project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const selectedProject = createMemo(() => projectForDirectory(sdk.directory))
|
const selectedProject = createMemo(() => projectForDirectory(props.controls.projects.directory))
|
||||||
const projectResults = createMemo(() => {
|
const projectResults = createMemo(() => {
|
||||||
const search = picker.projectSearch.trim().toLowerCase()
|
const search = picker.projectSearch.trim().toLowerCase()
|
||||||
if (!search) return projects()
|
if (!search) return projects()
|
||||||
return projects().filter((project) => displayName(project).toLowerCase().includes(search))
|
return projects().filter((project) => displayName(project).toLowerCase().includes(search))
|
||||||
})
|
})
|
||||||
const showAgentControl = createMemo(() => settings.general.showCustomAgents() && agentNames().length > 0)
|
const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
|
||||||
const selectProject = (worktree: string) => {
|
const selectProject = (worktree: string) => {
|
||||||
setPicker({
|
setPicker({
|
||||||
projectOpen: false,
|
projectOpen: false,
|
||||||
@@ -1382,33 +1416,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
restoreFocus()
|
restoreFocus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
layout.projects.open(worktree)
|
props.controls.projects.select(worktree)
|
||||||
server.projects.touch(worktree)
|
restoreFocus()
|
||||||
|
|
||||||
// On the draft route, retarget the existing draft in place so we keep the same
|
|
||||||
// draft id (and its tab/prompt) instead of spawning a new draft for the new directory.
|
|
||||||
const draftID = search.draftId
|
|
||||||
if (draftID) {
|
|
||||||
tabsStore.updateDraft(draftID, { server: server.key, directory: worktree })
|
|
||||||
restoreFocus()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate(`/${base64Encode(worktree)}/session`)
|
|
||||||
}
|
}
|
||||||
const addProject = () => {
|
const addProject = () => {
|
||||||
const conn = server.current
|
props.controls.projects.add(language.t("command.project.open"))
|
||||||
if (!conn) return
|
|
||||||
const select = (result: string | string[] | null) => {
|
|
||||||
const directory = Array.isArray(result) ? result[0] : result
|
|
||||||
if (!directory) return
|
|
||||||
selectProject(directory)
|
|
||||||
}
|
|
||||||
pickDirectory({
|
|
||||||
server: conn,
|
|
||||||
title: language.t("command.project.open"),
|
|
||||||
onSelect: select,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectPickerState = createMemo<ComposerPickerState>(() => ({
|
const projectPickerState = createMemo<ComposerPickerState>(() => ({
|
||||||
@@ -1449,11 +1461,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
|
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
|
||||||
title: language.t("command.agent.cycle"),
|
title: language.t("command.agent.cycle"),
|
||||||
keybind: command.keybind("agent.cycle"),
|
keybind: command.keybind("agent.cycle"),
|
||||||
options: agentNames(),
|
options: props.controls.agents.options,
|
||||||
current: local.agent.current()?.name ?? "",
|
current: props.controls.agents.current,
|
||||||
style: control(),
|
style: control(),
|
||||||
onSelect: (value) => {
|
onSelect: (value) => {
|
||||||
local.agent.set(value)
|
props.controls.agents.select(value)
|
||||||
restoreFocus()
|
restoreFocus()
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
@@ -1485,7 +1497,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||||
/>
|
/>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={settings.general.newLayoutDesigns()}>
|
<Match when={props.controls.newLayoutDesigns}>
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<DockShellForm
|
<DockShellForm
|
||||||
data-component={newSession() ? "session-new-composer" : "session-composer"}
|
data-component={newSession() ? "session-new-composer" : "session-composer"}
|
||||||
@@ -1605,7 +1617,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
data-component="prompt-variant-control"
|
data-component="prompt-variant-control"
|
||||||
classList={{
|
classList={{
|
||||||
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
||||||
!local.model.variant.current() && !store.variantOpen,
|
!props.controls.model.selection.variant.current() && !store.variantOpen,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
@@ -1617,11 +1629,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<Select
|
<Select
|
||||||
size="normal"
|
size="normal"
|
||||||
options={variants()}
|
options={variants()}
|
||||||
current={local.model.variant.current() ?? "default"}
|
current={props.controls.model.selection.variant.current() ?? "default"}
|
||||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||||
onOpenChange={(open) => setStore("variantOpen", open)}
|
onOpenChange={(open) => setStore("variantOpen", open)}
|
||||||
onSelect={(value) => {
|
onSelect={(value) => {
|
||||||
local.model.variant.set(value === "default" ? undefined : value)
|
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
|
||||||
restoreFocus()
|
restoreFocus()
|
||||||
}}
|
}}
|
||||||
class="capitalize max-w-[160px] justify-start text-v2-text-text-faint"
|
class="capitalize max-w-[160px] justify-start text-v2-text-text-faint"
|
||||||
@@ -1861,10 +1873,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
size="normal"
|
size="normal"
|
||||||
options={agentNames()}
|
options={props.controls.agents.options}
|
||||||
current={local.agent.current()?.name ?? ""}
|
current={props.controls.agents.current}
|
||||||
onSelect={(value) => {
|
onSelect={(value) => {
|
||||||
local.agent.set(value)
|
props.controls.agents.select(value)
|
||||||
restoreFocus()
|
restoreFocus()
|
||||||
}}
|
}}
|
||||||
class="capitalize max-w-[160px] text-text-base"
|
class="capitalize max-w-[160px] text-text-base"
|
||||||
@@ -1883,7 +1895,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={providers.paid().length > 0}
|
when={props.controls.model.paid}
|
||||||
fallback={
|
fallback={
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
@@ -1900,19 +1912,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
style={control()}
|
style={control()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||||
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
dialog.show(() => (
|
||||||
|
<x.DialogSelectModelUnpaid model={props.controls.model.selection} />
|
||||||
|
))
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={local.model.current()?.provider?.id}>
|
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
id={local.model.current()?.provider?.id ?? ""}
|
id={props.controls.model.selection.current()?.provider?.id ?? ""}
|
||||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<span class="truncate">
|
<span class="truncate">
|
||||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
{props.controls.model.selection.current()?.name ??
|
||||||
|
language.t("dialog.model.select.title")}
|
||||||
</span>
|
</span>
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1926,7 +1941,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
keybind={command.keybind("model.choose")}
|
keybind={command.keybind("model.choose")}
|
||||||
>
|
>
|
||||||
<ModelSelectorPopover
|
<ModelSelectorPopover
|
||||||
model={local.model}
|
model={props.controls.model.selection}
|
||||||
triggerAs={Button}
|
triggerAs={Button}
|
||||||
triggerProps={{
|
triggerProps={{
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
@@ -1937,15 +1952,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
onClose={restoreFocus}
|
onClose={restoreFocus}
|
||||||
>
|
>
|
||||||
<Show when={local.model.current()?.provider?.id}>
|
<Show when={props.controls.model.selection.current()?.provider?.id}>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
id={local.model.current()?.provider?.id ?? ""}
|
id={props.controls.model.selection.current()?.provider?.id ?? ""}
|
||||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<span class="truncate">
|
<span class="truncate">
|
||||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
{props.controls.model.selection.current()?.name ??
|
||||||
|
language.t("dialog.model.select.title")}
|
||||||
</span>
|
</span>
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
</ModelSelectorPopover>
|
</ModelSelectorPopover>
|
||||||
@@ -1966,10 +1982,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<Select
|
<Select
|
||||||
size="normal"
|
size="normal"
|
||||||
options={variants()}
|
options={variants()}
|
||||||
current={local.model.variant.current() ?? "default"}
|
current={props.controls.model.selection.variant.current() ?? "default"}
|
||||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||||
onSelect={(value) => {
|
onSelect={(value) => {
|
||||||
local.model.variant.set(value === "default" ? undefined : value)
|
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
|
||||||
restoreFocus()
|
restoreFocus()
|
||||||
}}
|
}}
|
||||||
class="capitalize max-w-[160px] text-text-base"
|
class="capitalize max-w-[160px] text-text-base"
|
||||||
@@ -2180,7 +2196,9 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
|||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<span class="truncate">{props.state.modelName}</span>
|
<span class="truncate">{props.state.modelName}</span>
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
<span class="-ml-1 shrink-0 flex size-fit">
|
||||||
|
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
}
|
}
|
||||||
@@ -2209,7 +2227,9 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
|||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<span class="truncate">{props.state.modelName}</span>
|
<span class="truncate">{props.state.modelName}</span>
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
<span class="-ml-1 shrink-0 flex size-fit">
|
||||||
|
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||||
|
</span>
|
||||||
</ModelSelectorPopover>
|
</ModelSelectorPopover>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { onMount } from "solid-js"
|
import { onMount } from "solid-js"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
|
import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { uuid } from "@/utils/uuid"
|
import { uuid } from "@/utils/uuid"
|
||||||
import { getCursorPosition } from "./editor-dom"
|
import { getCursorPosition } from "./editor-dom"
|
||||||
@@ -25,36 +25,46 @@ function dataUrl(file: File, mime: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
|
||||||
|
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
|
||||||
|
|
||||||
|
type PromptAttachmentsCoreInput = {
|
||||||
|
capture: () => PromptTarget
|
||||||
|
editor: () => HTMLDivElement | undefined
|
||||||
|
focusEditor?: () => void
|
||||||
|
addPart?: (part: ContentPart) => boolean
|
||||||
|
warn?: () => void
|
||||||
|
readClipboardImage?: () => Promise<File | null>
|
||||||
|
getPathForFile?: (file: File) => string
|
||||||
|
}
|
||||||
|
|
||||||
type PromptAttachmentsInput = {
|
type PromptAttachmentsInput = {
|
||||||
|
prompt: ReturnType<typeof usePrompt>
|
||||||
editor: () => HTMLDivElement | undefined
|
editor: () => HTMLDivElement | undefined
|
||||||
isDialogActive: () => boolean
|
isDialogActive: () => boolean
|
||||||
setDraggingType: (type: "image" | "@mention" | null) => void
|
setDraggingType: (type: "image" | "@mention" | null) => void
|
||||||
focusEditor: () => void
|
focusEditor: () => void
|
||||||
addPart: (part: ContentPart) => boolean
|
addPart: (part: ContentPart) => boolean
|
||||||
readClipboardImage?: () => Promise<File | null>
|
readClipboardImage?: () => Promise<File | null>
|
||||||
|
getPathForFile?: (file: File) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
|
||||||
const prompt = usePrompt()
|
const capture = (): AttachmentTarget | undefined => {
|
||||||
const language = useLanguage()
|
const prompt = input.capture()
|
||||||
|
const editor = input.editor()
|
||||||
const warn = () => {
|
if (!editor) return
|
||||||
showToast({
|
return { prompt, cursor: prompt.cursor() ?? getCursorPosition(editor) }
|
||||||
title: language.t("prompt.toast.pasteUnsupported.title"),
|
|
||||||
description: language.t("prompt.toast.pasteUnsupported.description"),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const add = async (file: File, toast = true) => {
|
const add = async (file: File, toast = true, target = capture()) => {
|
||||||
|
if (!target) return false
|
||||||
const mime = await attachmentMime(file)
|
const mime = await attachmentMime(file)
|
||||||
if (!mime) {
|
if (!mime) {
|
||||||
if (toast) warn()
|
if (toast) input.warn?.()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const editor = input.editor()
|
|
||||||
if (!editor) return false
|
|
||||||
|
|
||||||
const url = await dataUrl(file, mime)
|
const url = await dataUrl(file, mime)
|
||||||
if (!url) return false
|
if (!url) return false
|
||||||
|
|
||||||
@@ -62,37 +72,46 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
type: "image",
|
type: "image",
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
|
sourcePath: input.getPathForFile?.(file) || undefined,
|
||||||
mime,
|
mime,
|
||||||
dataUrl: url,
|
dataUrl: url,
|
||||||
}
|
}
|
||||||
const cursor = prompt.cursor() ?? getCursorPosition(editor)
|
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
|
||||||
prompt.set([...prompt.current(), attachment], cursor)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const addAttachment = (file: File) => add(file)
|
const addAttachment = (file: File) => add(file)
|
||||||
|
|
||||||
const addAttachments = async (files: File[], toast = true) => {
|
const addAttachments = async (files: File[], toast = true, target = capture()) => {
|
||||||
let found = false
|
let found = false
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const ok = await add(file, false)
|
const ok = await add(file, false, target)
|
||||||
if (ok) found = true
|
if (ok) found = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!found && files.length > 0 && toast) warn()
|
if (!found && files.length > 0 && toast) input.warn?.()
|
||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addClipboardAttachment = async (pending: Promise<File | null>, target = capture()) => {
|
||||||
|
const file = await pending
|
||||||
|
if (!file) return false
|
||||||
|
return add(file, true, target)
|
||||||
|
}
|
||||||
|
|
||||||
const removeAttachment = (id: string) => {
|
const removeAttachment = (id: string) => {
|
||||||
const current = prompt.current()
|
const target = input.capture()
|
||||||
|
const current = target.current()
|
||||||
const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
||||||
prompt.set(next, prompt.cursor())
|
target.set(next, target.cursor())
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePaste = async (event: ClipboardEvent) => {
|
const handlePaste = async (event: ClipboardEvent) => {
|
||||||
const clipboardData = event.clipboardData
|
const clipboardData = event.clipboardData
|
||||||
if (!clipboardData) return
|
if (!clipboardData) return
|
||||||
|
const target = capture()
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
@@ -104,7 +123,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
await addAttachments(files)
|
await addAttachments(files, true, target)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,11 +131,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
|
|
||||||
// Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images
|
// Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images
|
||||||
if (input.readClipboardImage && !plainText) {
|
if (input.readClipboardImage && !plainText) {
|
||||||
const file = await input.readClipboardImage()
|
if (await addClipboardAttachment(input.readClipboardImage(), target)) return
|
||||||
if (file) {
|
|
||||||
await addAttachment(file)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!plainText) return
|
if (!plainText) return
|
||||||
@@ -124,9 +139,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
const text = normalizePaste(plainText)
|
const text = normalizePaste(plainText)
|
||||||
|
|
||||||
const put = () => {
|
const put = () => {
|
||||||
if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true
|
if (input.addPart?.({ type: "text", content: text, start: 0, end: 0 })) return true
|
||||||
input.focusEditor()
|
input.focusEditor?.()
|
||||||
return input.addPart({ type: "text", content: text, start: 0, end: 0 })
|
return input.addPart?.({ type: "text", content: text, start: 0, end: 0 }) ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pasteMode(text) === "manual") {
|
if (pasteMode(text) === "manual") {
|
||||||
@@ -140,6 +155,28 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
put()
|
put()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
addAttachment,
|
||||||
|
addAttachments,
|
||||||
|
addClipboardAttachment,
|
||||||
|
removeAttachment,
|
||||||
|
handlePaste,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const attachments = createPromptAttachmentsCore({
|
||||||
|
...input,
|
||||||
|
capture: input.prompt.capture,
|
||||||
|
warn: () => {
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.pasteUnsupported.title"),
|
||||||
|
description: language.t("prompt.toast.pasteUnsupported.description"),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const handleGlobalDragOver = (event: DragEvent) => {
|
const handleGlobalDragOver = (event: DragEvent) => {
|
||||||
if (input.isDialogActive()) return
|
if (input.isDialogActive()) return
|
||||||
|
|
||||||
@@ -178,7 +215,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
const dropped = event.dataTransfer?.files
|
const dropped = event.dataTransfer?.files
|
||||||
if (!dropped) return
|
if (!dropped) return
|
||||||
|
|
||||||
await addAttachments(Array.from(dropped))
|
await attachments.addAttachments(Array.from(dropped))
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -187,10 +224,5 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
makeEventListener(document, "drop", handleGlobalDrop)
|
makeEventListener(document, "drop", handleGlobalDrop)
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return attachments
|
||||||
addAttachment,
|
|
||||||
addAttachments,
|
|
||||||
removeAttachment,
|
|
||||||
handlePaste,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,31 @@ describe("buildRequestParts", () => {
|
|||||||
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
|
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("preserves an external attachment source path for the model", () => {
|
||||||
|
const result = buildRequestParts({
|
||||||
|
prompt: [],
|
||||||
|
context: [],
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
id: "img_external",
|
||||||
|
filename: "opencode.global.dat",
|
||||||
|
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
|
||||||
|
mime: "text/plain",
|
||||||
|
dataUrl: "data:text/plain;base64,AAA",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
text: "inspect this",
|
||||||
|
messageID: "msg_external",
|
||||||
|
sessionID: "ses_external",
|
||||||
|
sessionDirectory: "C:\\Repos\\sst\\opencode",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.requestParts.find((part) => part.type === "file")?.filename).toBe(
|
||||||
|
"C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test("deduplicates context files when prompt already includes same path", () => {
|
test("deduplicates context files when prompt already includes same path", () => {
|
||||||
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
||||||
|
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
|||||||
type: "file",
|
type: "file",
|
||||||
mime: attachment.mime,
|
mime: attachment.mime,
|
||||||
url: attachment.dataUrl,
|
url: attachment.dataUrl,
|
||||||
filename: attachment.filename,
|
filename: attachment.sourcePath ?? attachment.filename,
|
||||||
} satisfies PromptRequestPart
|
} satisfies PromptRequestPart
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||||
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
<div class="flex items-center text-[12px] min-w-0 font-medium leading-5">
|
||||||
<span class="text-text-strong whitespace-nowrap">{label}</span>
|
<span class="text-text-strong whitespace-nowrap">{label}</span>
|
||||||
<Show when={item.selection}>
|
<Show when={item.selection}>
|
||||||
{(sel) => (
|
{(sel) => (
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { type ContextItem, type Prompt, type usePrompt } from "@/context/prompt"
|
||||||
|
|
||||||
|
type PromptTarget = ReturnType<ReturnType<typeof usePrompt>["capture"]>
|
||||||
|
|
||||||
|
export function createPromptSubmissionState(input: {
|
||||||
|
target: PromptTarget
|
||||||
|
prompt: Prompt
|
||||||
|
context: (ContextItem & { key: string })[]
|
||||||
|
}) {
|
||||||
|
let target = input.target
|
||||||
|
let cleared: Prompt | undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
prompt: input.prompt,
|
||||||
|
context: input.context,
|
||||||
|
target: () => target,
|
||||||
|
clear() {
|
||||||
|
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 }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,12 +20,31 @@ const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
|||||||
const promoted: Array<{ directory: string; sessionID: string }> = []
|
const promoted: Array<{ directory: string; sessionID: string }> = []
|
||||||
const sentShell: string[] = []
|
const sentShell: string[] = []
|
||||||
const syncedDirectories: string[] = []
|
const syncedDirectories: string[] = []
|
||||||
|
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
||||||
|
|
||||||
let params: { id?: string } = {}
|
let params: { id?: string } = {}
|
||||||
|
let search: { draftId?: string } = {}
|
||||||
let selected = "/repo/worktree-a"
|
let selected = "/repo/worktree-a"
|
||||||
let variant: string | undefined
|
let variant: string | undefined
|
||||||
|
|
||||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
|
const prompt = {
|
||||||
|
ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
|
||||||
|
current: () => promptValue,
|
||||||
|
cursor: () => 0,
|
||||||
|
dirty: () => true,
|
||||||
|
reset: () => undefined,
|
||||||
|
set: () => undefined,
|
||||||
|
context: {
|
||||||
|
add: () => undefined,
|
||||||
|
remove: () => undefined,
|
||||||
|
removeComment: () => undefined,
|
||||||
|
updateComment: () => undefined,
|
||||||
|
replaceComments: () => undefined,
|
||||||
|
items: () => [],
|
||||||
|
},
|
||||||
|
capture: () => prompt,
|
||||||
|
}
|
||||||
|
|
||||||
const clientFor = (directory: string) => {
|
const clientFor = (directory: string) => {
|
||||||
createdClients.push(directory)
|
createdClients.push(directory)
|
||||||
@@ -62,7 +81,7 @@ beforeAll(async () => {
|
|||||||
useNavigate: () => () => undefined,
|
useNavigate: () => () => undefined,
|
||||||
useParams: () => params,
|
useParams: () => params,
|
||||||
useLocation: () => ({}),
|
useLocation: () => ({}),
|
||||||
useSearchParams: () => [{}, () => undefined],
|
useSearchParams: () => [search, () => undefined],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
||||||
@@ -73,6 +92,7 @@ beforeAll(async () => {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@opencode-ai/ui/toast", () => ({
|
mock.module("@opencode-ai/ui/toast", () => ({
|
||||||
|
Toast: { Region: () => null },
|
||||||
showToast: () => 0,
|
showToast: () => 0,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -111,21 +131,15 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
mock.module("@/context/tabs", () => ({
|
mock.module("@/context/tabs", () => ({
|
||||||
useTabs: () => ({
|
useTabs: () => ({
|
||||||
promoteDraft: () => undefined,
|
draft: () => ({ server: "project-server" }),
|
||||||
|
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
|
||||||
|
promotedDrafts.push({ draftID, ...session })
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/prompt", () => ({
|
mock.module("@/context/prompt", () => ({
|
||||||
usePrompt: () => ({
|
usePrompt: () => prompt,
|
||||||
current: () => promptValue,
|
|
||||||
reset: () => undefined,
|
|
||||||
set: () => undefined,
|
|
||||||
context: {
|
|
||||||
add: () => undefined,
|
|
||||||
remove: () => undefined,
|
|
||||||
items: () => [],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/layout", () => ({
|
mock.module("@/context/layout", () => ({
|
||||||
@@ -147,12 +161,12 @@ beforeAll(async () => {
|
|||||||
return clientFor(opts.directory)
|
return clientFor(opts.directory)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return sdk
|
return () => sdk
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/sync", () => ({
|
mock.module("@/context/sync", () => ({
|
||||||
useSync: () => ({
|
useSync: () => () => ({
|
||||||
data: { command: [] },
|
data: { command: [] },
|
||||||
session: {
|
session: {
|
||||||
optimistic: {
|
optimistic: {
|
||||||
@@ -176,7 +190,11 @@ beforeAll(async () => {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/server-sync", () => ({
|
mock.module("@/context/server-sync", () => ({
|
||||||
useServerSync: () => ({
|
useServerSync: () => () => ({
|
||||||
|
session: {
|
||||||
|
remember: () => undefined,
|
||||||
|
set: () => undefined,
|
||||||
|
},
|
||||||
child: (directory: string) => {
|
child: (directory: string) => {
|
||||||
syncedDirectories.push(directory)
|
syncedDirectories.push(directory)
|
||||||
storedSessions[directory] ??= []
|
storedSessions[directory] ??= []
|
||||||
@@ -221,7 +239,9 @@ beforeEach(() => {
|
|||||||
optimistic.length = 0
|
optimistic.length = 0
|
||||||
optimisticSeeded.length = 0
|
optimisticSeeded.length = 0
|
||||||
promoted.length = 0
|
promoted.length = 0
|
||||||
|
promotedDrafts.length = 0
|
||||||
params = {}
|
params = {}
|
||||||
|
search = {}
|
||||||
sentShell.length = 0
|
sentShell.length = 0
|
||||||
syncedDirectories.length = 0
|
syncedDirectories.length = 0
|
||||||
selected = "/repo/worktree-a"
|
selected = "/repo/worktree-a"
|
||||||
@@ -232,6 +252,7 @@ beforeEach(() => {
|
|||||||
describe("prompt submit worktree selection", () => {
|
describe("prompt submit worktree selection", () => {
|
||||||
test("reads the latest worktree accessor value per submit", async () => {
|
test("reads the latest worktree accessor value per submit", async () => {
|
||||||
const submit = createPromptSubmit({
|
const submit = createPromptSubmit({
|
||||||
|
prompt,
|
||||||
info: () => undefined,
|
info: () => undefined,
|
||||||
imageAttachments: () => [],
|
imageAttachments: () => [],
|
||||||
commentCount: () => 0,
|
commentCount: () => 0,
|
||||||
@@ -269,6 +290,7 @@ describe("prompt submit worktree selection", () => {
|
|||||||
|
|
||||||
test("applies auto-accept to newly created sessions", async () => {
|
test("applies auto-accept to newly created sessions", async () => {
|
||||||
const submit = createPromptSubmit({
|
const submit = createPromptSubmit({
|
||||||
|
prompt,
|
||||||
info: () => undefined,
|
info: () => undefined,
|
||||||
imageAttachments: () => [],
|
imageAttachments: () => [],
|
||||||
commentCount: () => 0,
|
commentCount: () => 0,
|
||||||
@@ -294,11 +316,39 @@ describe("prompt submit worktree selection", () => {
|
|||||||
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
|
expect(enabledAutoAccept).toEqual([{ 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" }])
|
||||||
|
})
|
||||||
|
|
||||||
test("includes the selected variant on optimistic prompts", async () => {
|
test("includes the selected variant on optimistic prompts", async () => {
|
||||||
params = { id: "session-1" }
|
params = { id: "session-1" }
|
||||||
variant = "high"
|
variant = "high"
|
||||||
|
|
||||||
const submit = createPromptSubmit({
|
const submit = createPromptSubmit({
|
||||||
|
prompt,
|
||||||
info: () => ({ id: "session-1" }),
|
info: () => ({ id: "session-1" }),
|
||||||
imageAttachments: () => [],
|
imageAttachments: () => [],
|
||||||
commentCount: () => 0,
|
commentCount: () => 0,
|
||||||
@@ -330,6 +380,7 @@ describe("prompt submit worktree selection", () => {
|
|||||||
|
|
||||||
test("seeds new sessions before optimistic prompts are added", async () => {
|
test("seeds new sessions before optimistic prompts are added", async () => {
|
||||||
const submit = createPromptSubmit({
|
const submit = createPromptSubmit({
|
||||||
|
prompt,
|
||||||
info: () => undefined,
|
info: () => undefined,
|
||||||
imageAttachments: () => [],
|
imageAttachments: () => [],
|
||||||
commentCount: () => 0,
|
commentCount: () => 0,
|
||||||
|
|||||||
@@ -4,23 +4,22 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
|
|||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { batch, 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 { useTabs } from "@/context/tabs"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK, type DirectorySDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync, type DirectorySync } from "@/context/sync"
|
||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||||
import { buildRequestParts } from "./build-request-parts"
|
import { buildRequestParts } from "./build-request-parts"
|
||||||
import { setCursorPosition } from "./editor-dom"
|
import { setCursorPosition } from "./editor-dom"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { ScopedKey } from "@/utils/server-scope"
|
import { ScopedKey } from "@/utils/server-scope"
|
||||||
|
import { createPromptSubmissionState } from "./submission-state"
|
||||||
|
|
||||||
type PendingPrompt = {
|
type PendingPrompt = {
|
||||||
abort: AbortController
|
abort: AbortController
|
||||||
@@ -40,9 +39,9 @@ export type FollowupDraft = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FollowupSendInput = {
|
type FollowupSendInput = {
|
||||||
client: ReturnType<typeof useSDK>["client"]
|
client: DirectorySDK["client"]
|
||||||
serverSync: ReturnType<typeof useServerSync>
|
serverSync: ServerSync
|
||||||
sync: ReturnType<typeof useSync>
|
sync: DirectorySync
|
||||||
draft: FollowupDraft
|
draft: FollowupDraft
|
||||||
messageID?: string
|
messageID?: string
|
||||||
optimisticBusy?: boolean
|
optimisticBusy?: boolean
|
||||||
@@ -56,16 +55,14 @@ const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttac
|
|||||||
export async function sendFollowupDraft(input: FollowupSendInput) {
|
export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||||
const text = draftText(input.draft.prompt)
|
const text = draftText(input.draft.prompt)
|
||||||
const images = draftImages(input.draft.prompt)
|
const images = draftImages(input.draft.prompt)
|
||||||
const [, setStore] = input.serverSync.child(input.draft.sessionDirectory)
|
|
||||||
|
|
||||||
const setBusy = () => {
|
const setBusy = () => {
|
||||||
if (!input.optimisticBusy) return
|
if (!input.optimisticBusy) return
|
||||||
setStore("session_status", input.draft.sessionID, { type: "busy" })
|
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "busy" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const setIdle = () => {
|
const setIdle = () => {
|
||||||
if (!input.optimisticBusy) return
|
if (!input.optimisticBusy) return
|
||||||
setStore("session_status", input.draft.sessionID, { type: "idle" })
|
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const wait = async () => {
|
const wait = async () => {
|
||||||
@@ -174,6 +171,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PromptSubmitInput = {
|
type PromptSubmitInput = {
|
||||||
|
prompt: ReturnType<typeof usePrompt>
|
||||||
info: Accessor<{ id: string } | undefined>
|
info: Accessor<{ id: string } | undefined>
|
||||||
imageAttachments: Accessor<ImageAttachmentPart[]>
|
imageAttachments: Accessor<ImageAttachmentPart[]>
|
||||||
commentCount: Accessor<number>
|
commentCount: Accessor<number>
|
||||||
@@ -195,15 +193,6 @@ type PromptSubmitInput = {
|
|||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type CommentItem = {
|
|
||||||
path: string
|
|
||||||
selection?: FileSelection
|
|
||||||
comment?: string
|
|
||||||
commentID?: string
|
|
||||||
commentOrigin?: "review" | "file"
|
|
||||||
preview?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPromptSubmit(input: PromptSubmitInput) {
|
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
@@ -211,14 +200,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const prompt = usePrompt()
|
const prompt = input.prompt
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const server = useServer()
|
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk.scope, sessionID)
|
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
||||||
|
|
||||||
const errorMessage = (err: unknown) => {
|
const errorMessage = (err: unknown) => {
|
||||||
if (err && typeof err === "object" && "data" in err) {
|
if (err && typeof err === "object" && "data" in err) {
|
||||||
@@ -233,9 +221,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return Promise.resolve()
|
if (!sessionID) return Promise.resolve()
|
||||||
|
|
||||||
serverSync.todo.set(sessionID, [])
|
serverSync().session.set("todo", sessionID, [])
|
||||||
const [, setStore] = serverSync.child(sdk.directory)
|
|
||||||
setStore("todo", sessionID, [])
|
|
||||||
|
|
||||||
input.onAbort?.()
|
input.onAbort?.()
|
||||||
|
|
||||||
@@ -247,16 +233,19 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
pending.delete(key)
|
pending.delete(key)
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
return sdk.client.session
|
return sdk()
|
||||||
.abort({
|
.client.session.abort({
|
||||||
sessionID,
|
sessionID,
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoreCommentItems = (items: CommentItem[]) => {
|
const restoreCommentItems = (
|
||||||
|
target: ReturnType<ReturnType<typeof usePrompt>["capture"]>,
|
||||||
|
items: (ContextItem & { key: string })[],
|
||||||
|
) => {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
prompt.context.add({
|
target.context.add({
|
||||||
type: "file",
|
type: "file",
|
||||||
path: item.path,
|
path: item.path,
|
||||||
selection: item.selection,
|
selection: item.selection,
|
||||||
@@ -268,20 +257,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeCommentItems = (items: { key: string }[]) => {
|
const clearContext = (target: ReturnType<ReturnType<typeof usePrompt>["capture"]>) => {
|
||||||
for (const item of items) {
|
for (const item of target.context.items()) {
|
||||||
prompt.context.remove(item.key)
|
target.context.remove(item.key)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearContext = () => {
|
|
||||||
for (const item of prompt.context.items()) {
|
|
||||||
prompt.context.remove(item.key)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const seed = (dir: string, info: Session) => {
|
const seed = (dir: string, info: Session) => {
|
||||||
const [, setStore] = serverSync.child(dir)
|
serverSync().session.remember(info)
|
||||||
|
const [, setStore] = serverSync().child(dir)
|
||||||
setStore("session", (list: Session[]) => {
|
setStore("session", (list: Session[]) => {
|
||||||
const result = Binary.search(list, info.id, (item) => item.id)
|
const result = Binary.search(list, info.id, (item) => item.id)
|
||||||
const next = [...list]
|
const next = [...list]
|
||||||
@@ -297,7 +281,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const handleSubmit = async (event: Event) => {
|
const handleSubmit = async (event: Event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
const currentPrompt = prompt.current()
|
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 text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
|
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||||
const images = input.imageAttachments().slice()
|
const images = input.imageAttachments().slice()
|
||||||
const mode = input.mode()
|
const mode = input.mode()
|
||||||
@@ -321,13 +312,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
input.addToHistory(currentPrompt, mode)
|
input.addToHistory(currentPrompt, mode)
|
||||||
input.resetHistoryNavigation()
|
input.resetHistoryNavigation()
|
||||||
|
|
||||||
const projectDirectory = sdk.directory
|
const projectDirectory = sdk().directory
|
||||||
const isNewSession = !params.id
|
const isNewSession = !params.id
|
||||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||||
|
|
||||||
let sessionDirectory = projectDirectory
|
let sessionDirectory = projectDirectory
|
||||||
let client = sdk.client
|
let client = sdk().client
|
||||||
|
|
||||||
if (isNewSession) {
|
if (isNewSession) {
|
||||||
if (worktreeSelection === "create") {
|
if (worktreeSelection === "create") {
|
||||||
@@ -349,7 +340,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
WorktreeState.pending(sdk.scope, createdWorktree.directory)
|
WorktreeState.pending(sdk().scope, createdWorktree.directory)
|
||||||
sessionDirectory = createdWorktree.directory
|
sessionDirectory = createdWorktree.directory
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,11 +349,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sessionDirectory !== projectDirectory) {
|
if (sessionDirectory !== projectDirectory) {
|
||||||
client = sdk.createClient({
|
client = sdk().createClient({
|
||||||
directory: sessionDirectory,
|
directory: sessionDirectory,
|
||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
})
|
})
|
||||||
serverSync.child(sessionDirectory)
|
serverSync().child(sessionDirectory)
|
||||||
}
|
}
|
||||||
|
|
||||||
input.onNewSessionWorktreeReset?.()
|
input.onNewSessionWorktreeReset?.()
|
||||||
@@ -387,13 +378,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
local.session.promote(sessionDirectory, session.id)
|
local.session.promote(sessionDirectory, session.id)
|
||||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||||
const draftID = search.draftId
|
const draftID = search.draftId
|
||||||
if (draftID)
|
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
|
||||||
tabs.promoteDraft(draftID, {
|
|
||||||
server: server.key,
|
|
||||||
dirBase64: base64Encode(sessionDirectory),
|
|
||||||
sessionId: session.id,
|
|
||||||
})
|
|
||||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||||
|
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -409,7 +396,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
providerID: currentModel.provider.id,
|
providerID: currentModel.provider.id,
|
||||||
}
|
}
|
||||||
const agent = currentAgent.name
|
const agent = currentAgent.name
|
||||||
const context = prompt.context.items().slice()
|
|
||||||
const draft: FollowupDraft = {
|
const draft: FollowupDraft = {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
sessionDirectory,
|
sessionDirectory,
|
||||||
@@ -421,13 +407,16 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const clearInput = () => {
|
const clearInput = () => {
|
||||||
prompt.reset()
|
submission.clear()
|
||||||
input.setMode("normal")
|
input.setMode("normal")
|
||||||
input.setPopover(null)
|
input.setPopover(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoreInput = () => {
|
const restoreInput = () => {
|
||||||
prompt.set(currentPrompt, input.promptLength(currentPrompt))
|
const restored = submission.restore()
|
||||||
|
if (!restored) return false
|
||||||
|
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
|
||||||
|
if (!submission.current(prompt.capture())) return true
|
||||||
input.setMode(mode)
|
input.setMode(mode)
|
||||||
input.setPopover(null)
|
input.setPopover(null)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -437,11 +426,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
setCursorPosition(editor, input.promptLength(currentPrompt))
|
setCursorPosition(editor, input.promptLength(currentPrompt))
|
||||||
input.queueScroll()
|
input.queueScroll()
|
||||||
})
|
})
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
||||||
input.onQueue?.(draft)
|
input.onQueue?.(draft)
|
||||||
clearContext()
|
clearContext(submission.target())
|
||||||
clearInput()
|
clearInput()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -470,7 +460,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
if (text.startsWith("/")) {
|
if (text.startsWith("/")) {
|
||||||
const [cmdName, ...args] = text.split(" ")
|
const [cmdName, ...args] = text.split(" ")
|
||||||
const commandName = cmdName.slice(1)
|
const commandName = cmdName.slice(1)
|
||||||
const customCommand = sync.data.command.find((c) => c.name === commandName)
|
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
||||||
if (customCommand) {
|
if (customCommand) {
|
||||||
clearInput()
|
clearInput()
|
||||||
client.session
|
client.session
|
||||||
@@ -504,32 +494,31 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const messageID = Identifier.ascending("message")
|
const messageID = Identifier.ascending("message")
|
||||||
|
|
||||||
const removeOptimisticMessage = () => {
|
const removeOptimisticMessage = () => {
|
||||||
sync.session.optimistic.remove({
|
sync().session.optimistic.remove({
|
||||||
directory: sessionDirectory,
|
directory: sessionDirectory,
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
messageID,
|
messageID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
removeCommentItems(commentItems)
|
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||||
clearInput()
|
clearInput()
|
||||||
|
|
||||||
const waitForWorktree = async () => {
|
const waitForWorktree = async () => {
|
||||||
const worktree = WorktreeState.get(sdk.scope, sessionDirectory)
|
const worktree = WorktreeState.get(sdk().scope, sessionDirectory)
|
||||||
if (!worktree || worktree.status !== "pending") return true
|
if (!worktree || worktree.status !== "pending") return true
|
||||||
|
|
||||||
if (sessionDirectory === projectDirectory) {
|
if (sessionDirectory === projectDirectory) {
|
||||||
sync.set("session_status", session.id, { type: "busy" })
|
sync().set("session_status", session.id, { type: "busy" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (sessionDirectory === projectDirectory) {
|
if (sessionDirectory === projectDirectory) {
|
||||||
sync.set("session_status", session.id, { type: "idle" })
|
sync().set("session_status", session.id, { type: "idle" })
|
||||||
}
|
}
|
||||||
removeOptimisticMessage()
|
removeOptimisticMessage()
|
||||||
restoreCommentItems(commentItems)
|
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||||
restoreInput()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pending.set(pendingKey(session.id), { abort: controller, cleanup })
|
pending.set(pendingKey(session.id), { abort: controller, cleanup })
|
||||||
@@ -559,12 +548,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}, timeoutMs)
|
}, timeoutMs)
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await Promise.race([WorktreeState.wait(sdk.scope, sessionDirectory), abortWait, timeout]).finally(
|
const result = await Promise.race([
|
||||||
() => {
|
WorktreeState.wait(sdk().scope, sessionDirectory),
|
||||||
if (timer.id === undefined) return
|
abortWait,
|
||||||
clearTimeout(timer.id)
|
timeout,
|
||||||
},
|
]).finally(() => {
|
||||||
)
|
if (timer.id === undefined) return
|
||||||
|
clearTimeout(timer.id)
|
||||||
|
})
|
||||||
pending.delete(pendingKey(session.id))
|
pending.delete(pendingKey(session.id))
|
||||||
if (controller.signal.aborted) return false
|
if (controller.signal.aborted) return false
|
||||||
if (result.status === "failed") throw new Error(result.message)
|
if (result.status === "failed") throw new Error(result.message)
|
||||||
@@ -573,8 +564,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
|
|
||||||
void sendFollowupDraft({
|
void sendFollowupDraft({
|
||||||
client,
|
client,
|
||||||
sync,
|
sync: sync(),
|
||||||
serverSync,
|
serverSync: serverSync(),
|
||||||
draft,
|
draft,
|
||||||
messageID,
|
messageID,
|
||||||
optimisticBusy: sessionDirectory === projectDirectory,
|
optimisticBusy: sessionDirectory === projectDirectory,
|
||||||
@@ -582,15 +573,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
pending.delete(pendingKey(session.id))
|
pending.delete(pendingKey(session.id))
|
||||||
if (sessionDirectory === projectDirectory) {
|
if (sessionDirectory === projectDirectory) {
|
||||||
sync.set("session_status", session.id, { type: "idle" })
|
sync().set("session_status", session.id, { type: "idle" })
|
||||||
}
|
}
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||||
description: errorMessage(err),
|
description: errorMessage(err),
|
||||||
})
|
})
|
||||||
removeOptimisticMessage()
|
removeOptimisticMessage()
|
||||||
restoreCommentItems(commentItems)
|
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||||
restoreInput()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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
|
||||||
|
historyIndex: number
|
||||||
|
savedPrompt: PromptHistoryEntry | null
|
||||||
|
placeholder: number
|
||||||
|
draggingType: "image" | "@mention" | null
|
||||||
|
mode: "normal" | "shell"
|
||||||
|
applyingHistory: boolean
|
||||||
|
variantOpen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
|
||||||
|
setStore({
|
||||||
|
popover: null,
|
||||||
|
historyIndex: -1,
|
||||||
|
savedPrompt: null,
|
||||||
|
draggingType: null,
|
||||||
|
mode: "normal",
|
||||||
|
applyingHistory: false,
|
||||||
|
variantOpen: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPromptInputTransientState(identity: Accessor<unknown>, placeholder: number) {
|
||||||
|
const [store, setStore] = createStore<PromptInputTransientState>({
|
||||||
|
popover: null,
|
||||||
|
historyIndex: -1,
|
||||||
|
savedPrompt: null,
|
||||||
|
placeholder,
|
||||||
|
draggingType: null,
|
||||||
|
mode: "normal",
|
||||||
|
applyingHistory: false,
|
||||||
|
variantOpen: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))
|
||||||
|
|
||||||
|
return [store, setStore] as const
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ export const ServerRowMenu: Component<{
|
|||||||
const isDefault = () => props.controller.defaultKey() === key
|
const isDefault = () => props.controller.defaultKey() === key
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuV2 gutter={4} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
|
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
|
||||||
<MenuV2.Trigger
|
<MenuV2.Trigger
|
||||||
as={IconButtonV2}
|
as={IconButtonV2}
|
||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function ServerHealthIndicator(props: { health?: ServerHealth }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"size-1.5 rounded-full shrink-0": true,
|
"size-1.5 rounded-full shrink-0 my-[3.5px]": true,
|
||||||
"bg-icon-success-base": props.health?.healthy === true,
|
"bg-icon-success-base": props.health?.healthy === true,
|
||||||
"bg-icon-critical-base": props.health?.healthy === false,
|
"bg-icon-critical-base": props.health?.healthy === false,
|
||||||
"bg-border-weak-base": props.health === undefined,
|
"bg-border-weak-base": props.health === undefined,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useLayout } from "@/context/layout"
|
|||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
@@ -33,7 +34,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
const file = useFile()
|
const file = useFile()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const providers = useProviders()
|
const sdk = useSDK()
|
||||||
|
const providers = useProviders(() => sdk().directory)
|
||||||
const { params, tabs, view } = useSessionLayout()
|
const { params, tabs, view } = useSessionLayout()
|
||||||
|
|
||||||
const variant = createMemo(() => props.variant ?? "button")
|
const variant = createMemo(() => props.variant ?? "button")
|
||||||
@@ -42,7 +44,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
pathFromTab: file.pathFromTab,
|
pathFromTab: file.pathFromTab,
|
||||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||||
})
|
})
|
||||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
||||||
|
|
||||||
const usd = createMemo(
|
const usd = createMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import { same } from "@/utils/same"
|
|||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||||
import { File } from "@opencode-ai/ui/file"
|
import { File } from "@opencode-ai/session-ui/file"
|
||||||
import { Markdown } from "@opencode-ai/ui/markdown"
|
import { Markdown } from "@opencode-ai/session-ui/markdown"
|
||||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||||
@@ -93,16 +94,17 @@ const emptyUserMessages: UserMessage[] = []
|
|||||||
export function SessionContextTab() {
|
export function SessionContextTab() {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const providers = useProviders()
|
const sdk = useSDK()
|
||||||
|
const providers = useProviders(() => sdk().directory)
|
||||||
const { params, view } = useSessionLayout()
|
const { params, view } = useSessionLayout()
|
||||||
|
|
||||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||||
|
|
||||||
const messages = createMemo(
|
const messages = createMemo(
|
||||||
() => {
|
() => {
|
||||||
const id = params.id
|
const id = params.id
|
||||||
if (!id) return emptyMessages
|
if (!id) return emptyMessages
|
||||||
return (sync.data.message[id] ?? []) as Message[]
|
return (sync().data.message[id] ?? []) as Message[]
|
||||||
},
|
},
|
||||||
emptyMessages,
|
emptyMessages,
|
||||||
{ equals: same },
|
{ equals: same },
|
||||||
@@ -180,7 +182,7 @@ export function SessionContextTab() {
|
|||||||
if (!c?.input) return []
|
if (!c?.input) return []
|
||||||
return estimateSessionContextBreakdown({
|
return estimateSessionContextBreakdown({
|
||||||
messages: messages(),
|
messages: messages(),
|
||||||
parts: sync.data.part as Record<string, Part[] | undefined>,
|
parts: sync().data.part as Record<string, Part[] | undefined>,
|
||||||
input: c.input,
|
input: c.input,
|
||||||
systemPrompt: systemPrompt(),
|
systemPrompt: systemPrompt(),
|
||||||
})
|
})
|
||||||
@@ -221,7 +223,7 @@ export function SessionContextTab() {
|
|||||||
let scroll: HTMLDivElement | undefined
|
let scroll: HTMLDivElement | undefined
|
||||||
let frame: number | undefined
|
let frame: number | undefined
|
||||||
let pending: { x: number; y: number } | undefined
|
let pending: { x: number; y: number } | undefined
|
||||||
const getParts = (id: string) => (sync.data.part[id] ?? []) as Part[]
|
const getParts = (id: string) => (sync().data.part[id] ?? []) as Part[]
|
||||||
|
|
||||||
const restoreScroll = () => {
|
const restoreScroll = () => {
|
||||||
const el = scroll
|
const el = scroll
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
|||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
|
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
import { Portal } from "solid-js/web"
|
import { Portal } from "solid-js/web"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -27,6 +28,9 @@ import { Persist, persisted } from "@/utils/persist"
|
|||||||
import { StatusPopover, StatusPopoverV2 } from "../status-popover"
|
import { StatusPopover, StatusPopoverV2 } from "../status-popover"
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } 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 { reviewTooltipKeybind } from "../command-tooltip-keybind"
|
||||||
|
|
||||||
const OPEN_APPS = [
|
const OPEN_APPS = [
|
||||||
"vscode",
|
"vscode",
|
||||||
@@ -155,11 +159,10 @@ export function SessionHeader() {
|
|||||||
})
|
})
|
||||||
const hotkey = createMemo(() => command.keybind("file.open"))
|
const hotkey = createMemo(() => command.keybind("file.open"))
|
||||||
const os = createMemo(() => detectOS(platform))
|
const os = createMemo(() => detectOS(platform))
|
||||||
const isDesktopV2 = createMemo(() => platform.platform === "desktop" && settings.general.newLayoutDesigns())
|
const isV2 = settings.general.newLayoutDesigns
|
||||||
const search = createMemo(() => (isDesktopV2() ? settings.general.showSearch() : true))
|
const search = settings.visibility.search
|
||||||
const tree = createMemo(() => (isDesktopV2() ? settings.general.showFileTree() : true))
|
const status = settings.visibility.status
|
||||||
const term = createMemo(() => (isDesktopV2() ? settings.general.showTerminal() : true))
|
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||||
const status = createMemo(() => (isDesktopV2() ? settings.general.showStatus() : true))
|
|
||||||
|
|
||||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||||
finder: true,
|
finder: true,
|
||||||
@@ -231,13 +234,14 @@ export function SessionHeader() {
|
|||||||
)
|
)
|
||||||
const opening = createMemo(() => openRequest.app !== undefined)
|
const opening = createMemo(() => openRequest.app !== undefined)
|
||||||
const tint = createMemo(() =>
|
const tint = createMemo(() =>
|
||||||
messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
|
messageAgentColor(params.id ? sync().data.message[params.id] : undefined, sync().data.agent),
|
||||||
)
|
)
|
||||||
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
|
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
|
||||||
statusVisible: status(),
|
statusVisible: status(),
|
||||||
statusLabel: language.t("status.popover.trigger"),
|
statusLabel: language.t("status.popover.trigger"),
|
||||||
reviewLabel: language.t("command.review.toggle"),
|
reviewLabel: language.t("command.review.toggle"),
|
||||||
reviewKeybind: command.keybind("review.toggle"),
|
reviewKeybind: reviewTooltipKeybind(command),
|
||||||
|
reviewVisible: isDesktop(),
|
||||||
reviewOpened: view().reviewPanel.opened(),
|
reviewOpened: view().reviewPanel.opened(),
|
||||||
onReviewToggle: () => view().reviewPanel.toggle(),
|
onReviewToggle: () => view().reviewPanel.toggle(),
|
||||||
}))
|
}))
|
||||||
@@ -322,7 +326,7 @@ export function SessionHeader() {
|
|||||||
{(mount) => (
|
{(mount) => (
|
||||||
<Portal mount={mount()}>
|
<Portal mount={mount()}>
|
||||||
<Show
|
<Show
|
||||||
when={isDesktopV2}
|
when={isV2}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Show when={projectDirectory()}>
|
<Show when={projectDirectory()}>
|
||||||
@@ -444,23 +448,21 @@ export function SessionHeader() {
|
|||||||
<StatusPopover />
|
<StatusPopover />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={term()}>
|
<TooltipKeybind
|
||||||
<TooltipKeybind
|
title={language.t("command.terminal.toggle")}
|
||||||
title={language.t("command.terminal.toggle")}
|
keybind={command.keybind("terminal.toggle")}
|
||||||
keybind={command.keybind("terminal.toggle")}
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
|
||||||
|
onClick={toggleTerminal}
|
||||||
|
aria-label={language.t("command.terminal.toggle")}
|
||||||
|
aria-expanded={view().terminal.opened()}
|
||||||
|
aria-controls="terminal-panel"
|
||||||
>
|
>
|
||||||
<Button
|
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
|
||||||
variant="ghost"
|
</Button>
|
||||||
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
|
</TooltipKeybind>
|
||||||
onClick={toggleTerminal}
|
|
||||||
aria-label={language.t("command.terminal.toggle")}
|
|
||||||
aria-expanded={view().terminal.opened()}
|
|
||||||
aria-controls="terminal-panel"
|
|
||||||
>
|
|
||||||
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<div class="hidden md:flex items-center gap-1 shrink-0">
|
<div class="hidden md:flex items-center gap-1 shrink-0">
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
@@ -479,32 +481,30 @@ export function SessionHeader() {
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
|
|
||||||
<Show when={tree()}>
|
<TooltipKeybind
|
||||||
<TooltipKeybind
|
title={language.t("command.fileTree.toggle")}
|
||||||
title={language.t("command.fileTree.toggle")}
|
keybind={command.keybind("fileTree.toggle")}
|
||||||
keybind={command.keybind("fileTree.toggle")}
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||||
|
onClick={() => layout.fileTree.toggle()}
|
||||||
|
aria-label={language.t("command.fileTree.toggle")}
|
||||||
|
aria-expanded={layout.fileTree.opened()}
|
||||||
|
aria-controls="file-tree-panel"
|
||||||
>
|
>
|
||||||
<Button
|
<div class="relative flex items-center justify-center size-4">
|
||||||
variant="ghost"
|
<Icon
|
||||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
size="small"
|
||||||
onClick={() => layout.fileTree.toggle()}
|
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
|
||||||
aria-label={language.t("command.fileTree.toggle")}
|
classList={{
|
||||||
aria-expanded={layout.fileTree.opened()}
|
"text-icon-strong": layout.fileTree.opened(),
|
||||||
aria-controls="file-tree-panel"
|
"text-icon-weak": !layout.fileTree.opened(),
|
||||||
>
|
}}
|
||||||
<div class="relative flex items-center justify-center size-4">
|
/>
|
||||||
<Icon
|
</div>
|
||||||
size="small"
|
</Button>
|
||||||
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
|
</TooltipKeybind>
|
||||||
classList={{
|
|
||||||
"text-icon-strong": layout.fileTree.opened(),
|
|
||||||
"text-icon-weak": !layout.fileTree.opened(),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -523,12 +523,15 @@ type SessionHeaderV2ActionsState = {
|
|||||||
statusVisible: boolean
|
statusVisible: boolean
|
||||||
statusLabel: string
|
statusLabel: string
|
||||||
reviewLabel: string
|
reviewLabel: string
|
||||||
reviewKeybind: string
|
reviewKeybind: string[]
|
||||||
|
reviewVisible: boolean
|
||||||
reviewOpened: boolean
|
reviewOpened: boolean
|
||||||
onReviewToggle: () => void
|
onReviewToggle: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Show when={props.state.statusVisible}>
|
<Show when={props.state.statusVisible}>
|
||||||
@@ -536,20 +539,32 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
|||||||
<StatusPopoverV2 />
|
<StatusPopoverV2 />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Show>
|
</Show>
|
||||||
<TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}>
|
<Show when={props.state.reviewVisible}>
|
||||||
<IconButtonV2
|
<TooltipV2
|
||||||
type="button"
|
placement="bottom"
|
||||||
variant="ghost-muted"
|
value={
|
||||||
size="large"
|
<>
|
||||||
class="!w-9 shrink-0"
|
{props.state.reviewLabel}
|
||||||
state={props.state.reviewOpened ? "pressed" : undefined}
|
<Show when={props.state.reviewKeybind.length > 0}>
|
||||||
onClick={props.state.onReviewToggle}
|
<KeybindV2 keys={props.state.reviewKeybind} variant="neutral" />
|
||||||
aria-label={props.state.reviewLabel}
|
</Show>
|
||||||
aria-expanded={props.state.reviewOpened}
|
</>
|
||||||
aria-controls="review-panel"
|
}
|
||||||
icon={<IconV2 name="sidebar-right" />}
|
>
|
||||||
/>
|
<IconButtonV2
|
||||||
</TooltipKeybind>
|
type="button"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="large"
|
||||||
|
class="!w-9 shrink-0"
|
||||||
|
state={props.state.reviewOpened ? "pressed" : undefined}
|
||||||
|
onClick={props.state.onReviewToggle}
|
||||||
|
aria-label={props.state.reviewLabel}
|
||||||
|
aria-expanded={props.state.reviewOpened}
|
||||||
|
aria-controls="review-panel"
|
||||||
|
icon={<IconV2 name="sidebar-right" />}
|
||||||
|
/>
|
||||||
|
</TooltipV2>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,24 +20,24 @@ export function NewSessionView(props: NewSessionViewProps) {
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const sandboxes = createMemo(() => sync.project?.sandboxes ?? [])
|
const sandboxes = createMemo(() => sync().project?.sandboxes ?? [])
|
||||||
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
|
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
|
||||||
const current = createMemo(() => {
|
const current = createMemo(() => {
|
||||||
const selection = props.worktree
|
const selection = props.worktree
|
||||||
if (options().includes(selection)) return selection
|
if (options().includes(selection)) return selection
|
||||||
return MAIN_WORKTREE
|
return MAIN_WORKTREE
|
||||||
})
|
})
|
||||||
const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory)
|
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||||
const isWorktree = createMemo(() => {
|
const isWorktree = createMemo(() => {
|
||||||
const project = sync.project
|
const project = sync().project
|
||||||
if (!project) return false
|
if (!project) return false
|
||||||
return sdk.directory !== project.worktree
|
return sdk().directory !== project.worktree
|
||||||
})
|
})
|
||||||
|
|
||||||
const label = (value: string) => {
|
const label = (value: string) => {
|
||||||
if (value === MAIN_WORKTREE) {
|
if (value === MAIN_WORKTREE) {
|
||||||
if (isWorktree()) return language.t("session.new.worktree.main")
|
if (isWorktree()) return language.t("session.new.worktree.main")
|
||||||
const branch = sync.data.vcs?.branch
|
const branch = sync().data.vcs?.branch
|
||||||
if (branch) return language.t("session.new.worktree.mainWithBranch", { branch })
|
if (branch) return language.t("session.new.worktree.mainWithBranch", { branch })
|
||||||
return language.t("session.new.worktree.main")
|
return language.t("session.new.worktree.main")
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ export function NewSessionView(props: NewSessionViewProps) {
|
|||||||
{label(current())}
|
{label(current())}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Show when={sync.project}>
|
<Show when={sync().project}>
|
||||||
{(project) => (
|
{(project) => (
|
||||||
<div class="flex items-start justify-center gap-3 min-h-5">
|
<div class="flex items-start justify-center gap-3 min-h-5">
|
||||||
<div class="text-12-medium text-text-weak leading-5 min-w-0 max-w-160 break-words text-center">
|
<div class="text-12-medium text-text-weak leading-5 min-w-0 max-w-160 break-words text-center">
|
||||||
|
|||||||
@@ -127,8 +127,8 @@ export const SettingsGeneral: Component = () => {
|
|||||||
|
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
() =>
|
() =>
|
||||||
serverSdk.client.pty
|
serverSdk()
|
||||||
.shells()
|
.client.pty.shells()
|
||||||
.then((res) => res.data ?? [])
|
.then((res) => res.data ?? [])
|
||||||
.catch(() => [] as ShellOption[]),
|
.catch(() => [] as ShellOption[]),
|
||||||
{ initialValue: [] as ShellOption[] },
|
{ initialValue: [] as ShellOption[] },
|
||||||
@@ -151,11 +151,11 @@ export const SettingsGeneral: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
|
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
|
||||||
const currentShell = createMemo(() => serverSync.data.config.shell ?? "")
|
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
|
||||||
|
|
||||||
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
||||||
const list = shells.latest
|
const list = shells.latest
|
||||||
const current = serverSync.data.config.shell
|
const current = serverSync().data.config.shell
|
||||||
|
|
||||||
const nameCounts = new Map<string, number>()
|
const nameCounts = new Map<string, number>()
|
||||||
for (const s of list) {
|
for (const s of list) {
|
||||||
@@ -290,7 +290,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
onSelect={(option) => {
|
onSelect={(option) => {
|
||||||
if (!option) return
|
if (!option) return
|
||||||
if (option.value === currentShell()) return
|
if (option.value === currentShell()) return
|
||||||
serverSync.updateConfig({ shell: option.value })
|
serverSync().updateConfig({ shell: option.value })
|
||||||
}}
|
}}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -335,18 +335,6 @@ export const SettingsGeneral: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.showSessionProgressBar.title")}
|
|
||||||
description={language.t("settings.general.row.showSessionProgressBar.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-show-session-progress-bar">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.showSessionProgressBar()}
|
|
||||||
onChange={(checked) => settings.general.setShowSessionProgressBar(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={language.t("settings.general.row.newLayoutDesigns.title")}
|
title={language.t("settings.general.row.newLayoutDesigns.title")}
|
||||||
description={language.t("settings.general.row.newLayoutDesigns.description")}
|
description={language.t("settings.general.row.newLayoutDesigns.description")}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user