mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 00:36:20 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 871c2dd76e |
@@ -2,4 +2,4 @@ blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 💬 Discord Community
|
||||
url: https://discord.gg/opencode
|
||||
about: For support, troubleshooting, how-to questions, and real-time discussion.
|
||||
about: For quick questions or real-time discussion. Note that issues are searchable and help others with the same question.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
name: Question
|
||||
description: Ask a question
|
||||
body:
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: Question
|
||||
description: What's your question?
|
||||
validations:
|
||||
required: true
|
||||
@@ -325,7 +325,6 @@ jobs:
|
||||
run: bun run build
|
||||
working-directory: packages/desktop
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||
|
||||
@@ -9,7 +9,6 @@ on:
|
||||
- "bun.lock"
|
||||
- "packages/storybook/**"
|
||||
- "packages/ui/**"
|
||||
- "packages/session-ui/**"
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
paths:
|
||||
@@ -18,7 +17,6 @@ on:
|
||||
- "bun.lock"
|
||||
- "packages/storybook/**"
|
||||
- "packages/ui/**"
|
||||
- "packages/session-ui/**"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -65,15 +65,10 @@ jobs:
|
||||
|
||||
- name: Run unit tests
|
||||
timeout-minutes: 20
|
||||
run: GITHUB_ACTIONS=false bun turbo test
|
||||
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
- name: Check generated client
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/client
|
||||
run: bun run check:generated
|
||||
|
||||
- name: Run HttpApi exerciser gates
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/opencode
|
||||
@@ -104,8 +99,7 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
# Playwright 1.59 hangs while extracting Chromium with Node 24.16.
|
||||
node-version: "24.15"
|
||||
node-version: "24"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
@@ -28,7 +28,6 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
|
||||
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
|
||||
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
|
||||
- In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module.
|
||||
- In Effect generators, bind services to named variables before calling methods. Do not use nested service yields such as `yield* (yield* Foo.Service).bar()`.
|
||||
|
||||
Reduce total variable count by inlining when a value is only used once.
|
||||
|
||||
@@ -138,7 +137,7 @@ const table = sqliteTable("session", {
|
||||
|
||||
## Testing
|
||||
|
||||
- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option.
|
||||
- Avoid mocks as much as possible
|
||||
- Test actual implementation, do not duplicate logic into tests
|
||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
||||
|
||||
@@ -153,7 +152,7 @@ const table = sqliteTable("session", {
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash activity recovery requires a separate explicit design before it may retry provider work.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default and coalesce into the active activity at the next safe provider-turn boundary. Explicit `queue` inputs open FIFO future activities one at a time after the active activity settles.
|
||||
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
||||
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.
|
||||
|
||||
+18
-100
@@ -24,7 +24,7 @@ A durable chronological instruction that tells the model the newly effective sta
|
||||
_Avoid_: System update, system notification, raw text diff
|
||||
|
||||
**Context Epoch**:
|
||||
The span during which one initially rendered **System Context** remains the immutable provider-cache baseline, ending at completed compaction, Session movement, or an incompatible context transition that requires a fresh baseline.
|
||||
The span during which one effective agent's initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition.
|
||||
|
||||
**Baseline System Context**:
|
||||
The full **System Context** rendered at the start of a **Context Epoch**.
|
||||
@@ -39,42 +39,22 @@ An expected temporary inability to observe a **Context Source** value; the runti
|
||||
**Safe Provider-Turn Boundary**:
|
||||
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
|
||||
|
||||
**Admitted Prompt**:
|
||||
A durable user input accepted into the Session inbox but not yet included in **Session History**.
|
||||
|
||||
**Prompt Promotion**:
|
||||
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
|
||||
|
||||
**Provider Turn**:
|
||||
One request to a model provider and the response projected from that request.
|
||||
|
||||
**Session Drain**:
|
||||
One process-local execution span that promotes eligible input and runs required **Provider Turns** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
|
||||
|
||||
**Model Tool Output**:
|
||||
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
|
||||
|
||||
**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.
|
||||
|
||||
**Model Request Options**:
|
||||
Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request.
|
||||
_Avoid_: Request body, wire options
|
||||
|
||||
**Generation Controls**:
|
||||
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
|
||||
|
||||
**PTY Environment**:
|
||||
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
|
||||
|
||||
**OpenCode Client**:
|
||||
The generated 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
|
||||
|
||||
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
|
||||
@@ -87,11 +67,6 @@ _Avoid_: Response envelope
|
||||
- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**.
|
||||
- Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes.
|
||||
- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
|
||||
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
|
||||
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
|
||||
- Steering prompts promote at the next **Safe Provider-Turn Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's provider-turn allowance; multiple prompts promoted at one boundary reset it once.
|
||||
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
|
||||
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, provider attempts, and tool state rather than inventing an enclosing execution identity.
|
||||
- The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline.
|
||||
- Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion.
|
||||
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
|
||||
@@ -100,74 +75,34 @@ _Avoid_: Response envelope
|
||||
- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed.
|
||||
- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**.
|
||||
- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
|
||||
- `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable.
|
||||
- `SystemContext.replace(...)` represents an explicit baseline-replacing transition such as compaction or model/provider switch; it either produces a fresh generation or reports that replacement is blocked by unavailable admitted context.
|
||||
- Context Epoch preparation retries until stable after optimistic revision mismatches so concurrent replacement requests cannot terminate an otherwise valid safe-boundary run.
|
||||
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
|
||||
- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
|
||||
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**.
|
||||
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
|
||||
- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote.
|
||||
- Context Epoch initialization is fenced against the authoritative Session Location, so an old-Location runner cannot recreate source context after a concurrent move.
|
||||
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
|
||||
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**.
|
||||
- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam.
|
||||
- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
|
||||
- The selected agent and model are sampled when a provider turn starts. Changes admitted after that boundary apply to the next provider turn and do not restart the current turn.
|
||||
- Selected-agent available-skill guidance remains a **Context Source**. An agent switch that changes that guidance produces a **Mid-Conversation System Message** while preserving the current baseline.
|
||||
- Switching the selected agent requests **Context Epoch** replacement. A switch admitted after the current **Safe Provider-Turn Boundary** applies to the next provider turn while leaving the already-prepared baseline durable. Epoch creation is fenced against the authoritative effective agent, and retries re-observe the current agent.
|
||||
- A cross-agent replacement must complete before another provider turn; unavailable admitted context blocks that replacement instead of exposing the previous agent's privileged baseline.
|
||||
- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy.
|
||||
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
|
||||
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
|
||||
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
|
||||
- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
|
||||
- A **Context Epoch** begins with one immutable **Baseline System Context**.
|
||||
- A **Context Epoch** durably records the effective agent that owns its **Baseline System Context**.
|
||||
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
|
||||
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
|
||||
- Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
|
||||
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
|
||||
- Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache.
|
||||
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
|
||||
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
||||
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
||||
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
|
||||
- Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs.
|
||||
- The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately.
|
||||
- Creating **Embedded OpenCode** is scoped. Closing its owning Scope releases the in-process server resources, database resources, registrations, and fibers.
|
||||
- **Embedded OpenCode** exposes shared client capabilities and embedded-only capabilities on one object; consumers do not navigate through a nested `.client` property.
|
||||
- The beta **OpenCode Client** currently uses plural consumer-facing capability groups such as `sessions`; whether the stable Session namespace should instead be singular `session` must be settled before stabilization. Internal server identifiers do not implicitly define public client names.
|
||||
- Server's concrete `HttpApi` is authoritative for shared **OpenCode Client** capabilities. Codegen compiles its Session group directly; the Effect runtime uses an equivalent Protocol-only projection so generated artifacts remain independent of Core and Server.
|
||||
- SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently.
|
||||
- The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR.
|
||||
- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against a Protocol-only client projection whose generated transport output is tested against Server's concrete API; the Promise emitter still derives zero-Effect structural wire types from the same IR.
|
||||
- `@opencode-ai/protocol` owns Session endpoint construction and middleware placement. Server supplies concrete middleware keys to produce the authoritative build-time API; the client projection supplies transport-only keys without importing Core or Server at runtime.
|
||||
- The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface.
|
||||
- The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy.
|
||||
- Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors.
|
||||
- Promise-client infrastructure failures use one generated `ClientError` class with a structured reason such as transport failure, unexpected status, unsupported content type, or malformed response. Promise methods reject with either a tagged declared domain failure or `ClientError`, matching the Effect client's conceptual domain/infrastructure error division.
|
||||
- Promise methods accept a separate optional per-call transport-options argument containing `AbortSignal` and header overrides. Cancellation and transport metadata do not enter the domain input object; broader interceptor and response-mode APIs remain deferred.
|
||||
- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly.
|
||||
- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction.
|
||||
- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client.
|
||||
- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides.
|
||||
- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation.
|
||||
- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently.
|
||||
- Promise and Effect network clients ship from `@opencode-ai/client` behind isolated root and `/effect` exports. The root has no runtime path to Effect; `/effect` imports only Effect, Schema, and Protocol.
|
||||
- The Effect-native scoped host belongs to `@opencode-ai/sdk-next`, which will assume the existing `@opencode-ai/sdk` name after legacy consumers migrate. Client remains network-only and SDK depends one-way on Client.
|
||||
- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
|
||||
- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
|
||||
- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
|
||||
- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
|
||||
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
|
||||
- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
|
||||
- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
|
||||
- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
|
||||
- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
|
||||
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
|
||||
- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
|
||||
- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor.
|
||||
- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid.
|
||||
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `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.
|
||||
- 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.
|
||||
@@ -184,23 +119,6 @@ _Avoid_: Response envelope
|
||||
- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
|
||||
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
|
||||
|
||||
## Client contract architecture
|
||||
|
||||
Semantic values that mean the same thing internally and publicly live in the lightweight Schema leaf. Core consumes Schema for domain behavior; Protocol composes Schema values into paths, payloads, envelopes, errors, cursors, and streams; Server imports both, hosts Protocol's exact groups, and owns protocol/domain adaptation. The root Promise client remains zero-Effect, `/effect` depends on Effect plus Schema and Protocol, and `@opencode-ai/sdk-next` composes the scoped in-process host above Client, Core, and Server.
|
||||
|
||||
Shared public records are plain objects declared with `Schema.Struct`. A same-name inferred interface gives object records readable TypeScript signatures without constructors, prototypes, or nominal identity; unions retain explicit type aliases.
|
||||
|
||||
Before stabilizing the client API:
|
||||
|
||||
- Keep additional public schemas in Schema and additional network groups in Protocol; neither package may transitively load databases, Drizzle, Session execution, providers, watchers, native modules, or WASM.
|
||||
- Keep concrete Location middleware keys in Server while Protocol owns their placement. Client projections may supply transport-only keys, but must prove generated equivalence with Server's concrete API.
|
||||
- Project the existing list response envelope to the stable client **Page** shape and enforce separate initial-query and cursor-continuation inputs without changing the hosted V2 wire contract.
|
||||
- Settle the stable consumer namespace (`session` versus the current beta `sessions`) and use an explicit codegen annotation if the consumer name should differ from the server group identifier.
|
||||
- Preserve V2 route paths, operation IDs, codecs, errors, middleware behavior, and OpenAPI output while making this change.
|
||||
- Preserve browser-safe `@opencode-ai/client` and `@opencode-ai/client/effect` bundles through import-boundary tests.
|
||||
- Define embedded-host placement before supporting multiple hosts over one database. Hosts that share durable Session storage must also share process-local Session execution coordination, or each host must receive isolated storage explicitly.
|
||||
- Keep an embedded request scope alive until any streamed response body finishes. The initial non-streaming Session surface does not exercise this lifetime boundary; Session and instance event streams must do so before joining the embedded client.
|
||||
|
||||
## Example dialogue
|
||||
|
||||
> **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?"
|
||||
|
||||
@@ -29,12 +29,11 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
"@sentry/solid": "catalog:",
|
||||
@@ -87,7 +86,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
},
|
||||
@@ -110,32 +109,9 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode-ai/client",
|
||||
"dependencies": {
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/httpapi-codegen": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-beta.83",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"effect",
|
||||
],
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -171,7 +147,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -198,7 +174,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -220,7 +196,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -244,7 +220,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
@@ -264,7 +240,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -300,8 +276,6 @@
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.9.0",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
@@ -357,7 +331,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"effect": "catalog:",
|
||||
@@ -411,7 +385,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -425,7 +399,7 @@
|
||||
},
|
||||
"packages/effect-sqlite-node": {
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -437,11 +411,10 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
@@ -469,7 +442,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -485,10 +458,10 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||
"@effect/platform-node": "4.0.0-beta.74",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.74",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -499,26 +472,13 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"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:",
|
||||
"effect": "4.0.0-beta.74",
|
||||
},
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
"aws4fetch": "1.0.20",
|
||||
@@ -535,7 +495,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -574,8 +534,6 @@
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
@@ -665,9 +623,8 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
"zod": "catalog:",
|
||||
@@ -682,9 +639,9 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.4.2",
|
||||
"@opentui/keymap": ">=0.4.2",
|
||||
"@opentui/solid": ">=0.4.2",
|
||||
"@opentui/core": ">=0.3.4",
|
||||
"@opentui/keymap": ">=0.3.4",
|
||||
"@opentui/solid": ">=0.3.4",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
@@ -692,29 +649,6 @@
|
||||
"@opentui/solid",
|
||||
],
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode-ai/protocol",
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode-ai/schema",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/script": {
|
||||
"name": "@opencode-ai/script",
|
||||
"dependencies": {
|
||||
@@ -725,23 +659,9 @@
|
||||
"@types/semver": "^7.5.8",
|
||||
},
|
||||
},
|
||||
"packages/sdk-next": {
|
||||
"name": "@opencode-ai/sdk-next",
|
||||
"dependencies": {
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -756,10 +676,9 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -769,53 +688,9 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode-ai/session-ui",
|
||||
"version": "1.17.10",
|
||||
"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": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -828,7 +703,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@opencode-ai/stats-core": "workspace:*",
|
||||
@@ -861,7 +736,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -880,7 +755,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -899,7 +774,6 @@
|
||||
"packages/storybook": {
|
||||
"name": "@opencode-ai/storybook",
|
||||
"devDependencies": {
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@storybook/addon-a11y": "^10.2.13",
|
||||
@@ -921,7 +795,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -948,9 +822,11 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@shikijs/stream": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
@@ -995,7 +871,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1059,9 +935,9 @@
|
||||
},
|
||||
"catalog": {
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@effect/opentelemetry": "4.0.0-beta.83",
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
|
||||
"@effect/opentelemetry": "4.0.0-beta.74",
|
||||
"@effect/platform-node": "4.0.0-beta.74",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.11",
|
||||
@@ -1069,9 +945,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.4.2",
|
||||
"@opentui/keymap": "0.4.2",
|
||||
"@opentui/solid": "0.4.2",
|
||||
"@opentui/core": "0.3.4",
|
||||
"@opentui/keymap": "0.3.4",
|
||||
"@opentui/solid": "0.3.4",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -1097,7 +973,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"effect": "4.0.0-beta.83",
|
||||
"effect": "4.0.0-beta.74",
|
||||
"fuzzysort": "3.1.0",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
@@ -1452,13 +1328,13 @@
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
||||
|
||||
"@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/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/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": ["@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-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/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/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.83", "", { "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-6OaxLsWffxkh9pXYUSyj/AxjVb9URY2rG9U6atjxClWy30Jx77R9Pm3Rrc7cQ63kQurePavEw1bQbzQ/SILiQQ=="],
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.74", "", { "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-RVMRVY7NhSoAp9cAAyy4TT6dt6NNZjOpWeqticoho9HNBukxQSUcu/kjcz4Iq9eoQfXadmepu8kZqtdZULM/fg=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -1896,8 +1772,6 @@
|
||||
|
||||
"@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-core": ["@opencode-ai/console-core@workspace:packages/console/core"],
|
||||
@@ -1924,26 +1798,16 @@
|
||||
|
||||
"@opencode-ai/http-recorder": ["@opencode-ai/http-recorder@workspace:packages/http-recorder"],
|
||||
|
||||
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
|
||||
|
||||
"@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
|
||||
|
||||
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
|
||||
|
||||
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
|
||||
|
||||
"@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"],
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"],
|
||||
|
||||
"@opencode-ai/sdk-next": ["@opencode-ai/sdk-next@workspace:packages/sdk-next"],
|
||||
|
||||
"@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"],
|
||||
|
||||
"@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"],
|
||||
|
||||
"@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
|
||||
|
||||
"@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"],
|
||||
@@ -1988,27 +1852,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.4.2", "", { "dependencies": { "bun-ffi-structs": "0.2.3", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.2", "@opentui/core-darwin-x64": "0.4.2", "@opentui/core-linux-arm64": "0.4.2", "@opentui/core-linux-arm64-musl": "0.4.2", "@opentui/core-linux-x64": "0.4.2", "@opentui/core-linux-x64-musl": "0.4.2", "@opentui/core-win32-arm64": "0.4.2", "@opentui/core-win32-x64": "0.4.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ulx6RMqftf2fm7Itf9e81GcCDMNY6NAhmnKYhllDOMYD+PxYXR+vomy2bxQNV5ow31RE7s8WQFnb7hWTRUbx2g=="],
|
||||
"@opentui/core": ["@opentui/core@0.3.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-is+O+sS/l3E9cZXyM9pRF1WhqnE+hYSPYoZkbseR9CthJcaWPGi3R3jUJa1cLj325252jWgxVupnDqFUtKg36w=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ACi42h81DurSeybUAD1XyKT6xmXZcKeTxS54lZFi0CVZh46w0g99vNj8PlQzIFXvvFLT0e0IlRS//eWSWS2zGQ=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-RjOx2HcjLRtGSy9WrAGSdr5M9SpJuPifPORpImx6Mciovw0ltnE0uoYjIyor82uf6/LExWC7YA2AcAl+YBxayA=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-heNciL2ngPU+kq1h01PHLsxn6Fr8iqTFtbxSdVbhaY3XihuIjkuXyEhFeuoa1lsXY7Bb2gpWnX5EQVWnZsAuDQ=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-9s0s/ooK+AhWP306By3gu+XhzcVEThC2sqKMPK1nQmGDujQhd+xOrtbtfCVcJSx62UzAovC2VNqypvP8vHByOg=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cjv6Bv7l3p/KLNJr5RyqCS0FmRlAGJnkA2IK3S+HkHhCOv/O02S1G+DBUY6POnyjp1eNy95vauustApobhdbig=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mfJZrJ0TNPFRZUzXNsxAPe1YdiWsy/vbTl93+yeXGHPI1B8Qnk9V5hpzSxxEyBGhlTHSfGNtgiO+VrrdRC3kZA=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-P2oguG3ng3OMjAdasFSA3GhHaQXtzDUsIRDGbzWFOimpZ/zMemidp+JQ0V8V6XwK6Utk5G0aQ03oBaRCoLyYDw=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.4.2", "", { "dependencies": { "@opentui/core": "0.4.2" }, "peerDependencies": { "@opentui/react": "0.4.2", "@opentui/solid": "0.4.2", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wxBEFfWgm3feqCRLckWg1JH4tbMJinpyK3yobkLTsWJ7PDsM+fPoFMyQ8ieKVdUL2eP6ELTmHvM1bHKShZ7SUQ=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.3.4", "", { "dependencies": { "@opentui/core": "0.3.4" }, "peerDependencies": { "@opentui/react": "0.3.4", "@opentui/solid": "0.3.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8fo6BZWQgCjANfbKkzPo0ghAzS1E7TlHjDDS+SUhrX01qEUO1clFTRssKluHbXd2UJY1Ehle01TV5bFmY78f8w=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.4.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zuYXsnrlsMtnXrS7QCYBdPzMtUSonG2LqnJikBR2NjEE2O4zEKvJd48n3eB1igcxjv96tiotTXRNCylYS0SNdQ=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -3186,7 +3050,7 @@
|
||||
|
||||
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
|
||||
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.2.3", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-pgJiXP+hEgFo9qG51J6ItfY4ocs3vniwNzJ9WhoakB3QB2GdzQxX2EXssentPYlB2hOfJrTjO6iIQkWYzUodpg=="],
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
@@ -3502,7 +3366,7 @@
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"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=="],
|
||||
"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=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
@@ -5544,6 +5408,8 @@
|
||||
|
||||
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||
|
||||
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
|
||||
|
||||
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
||||
|
||||
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
|
||||
@@ -5962,8 +5828,6 @@
|
||||
|
||||
"@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/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
||||
@@ -6034,10 +5898,6 @@
|
||||
|
||||
"@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
|
||||
|
||||
"@standard-community/standard-json/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
|
||||
|
||||
"@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
|
||||
|
||||
"@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
|
||||
|
||||
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
@@ -6840,10 +6700,6 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -256,7 +256,6 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
SECRET.UpstashRedisRestToken,
|
||||
AUTH_API_URL,
|
||||
STRIPE_WEBHOOK_SECRET,
|
||||
SECRET.SupportApiKey,
|
||||
DISCORD_INCIDENT_WEBHOOK_URL,
|
||||
SECRET.HoneycombWebhookSecret,
|
||||
STRIPE_SECRET_KEY,
|
||||
|
||||
@@ -7,7 +7,6 @@ new sst.cloudflare.x.SolidStart("Teams", {
|
||||
domain: shortDomain,
|
||||
path: "packages/enterprise",
|
||||
buildCommand: "bun run build:cloudflare",
|
||||
link: [SECRET.SupportApiKey],
|
||||
environment: {
|
||||
OPENCODE_STORAGE_ADAPTER: "r2",
|
||||
OPENCODE_STORAGE_ACCOUNT_ID: sst.cloudflare.DEFAULT_ACCOUNT_ID,
|
||||
|
||||
@@ -9,7 +9,6 @@ export const SECRET = {
|
||||
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
|
||||
HoneycombApiKey: new sst.Secret("HONEYCOMB_API_KEY"),
|
||||
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
|
||||
SupportApiKey: new sst.Secret("SUPPORT_API_KEY"),
|
||||
UpstashRedisRestUrl: new sst.Secret("UpstashRedisRestUrl"),
|
||||
UpstashRedisRestToken: new sst.Secret("UpstashRedisRestToken"),
|
||||
}
|
||||
|
||||
+2
-4
@@ -42,12 +42,11 @@ const inferenceEventTable = new aws.s3tables.Table(
|
||||
{ name: "request", type: "string", required: false },
|
||||
{ name: "client", type: "string", required: false },
|
||||
{ name: "user_agent", type: "string", required: false },
|
||||
{ name: "model", type: "string", required: false },
|
||||
{ name: "model_tier", type: "string", required: false },
|
||||
{ name: "model_variant", type: "string", required: false },
|
||||
{ name: "source", type: "string", required: false },
|
||||
{ name: "provider", type: "string", required: false },
|
||||
{ name: "provider_model", type: "string", required: false },
|
||||
{ name: "model", type: "string", required: false },
|
||||
{ name: "llm_error_code", type: "int", required: false },
|
||||
{ name: "llm_error_message", type: "string", required: false },
|
||||
{ name: "error_response", type: "string", required: false },
|
||||
@@ -57,7 +56,6 @@ const inferenceEventTable = new aws.s3tables.Table(
|
||||
{ name: "error_cause2", type: "string", required: false },
|
||||
{ name: "api_key", type: "string", required: false },
|
||||
{ name: "workspace", type: "string", required: false },
|
||||
{ name: "user_id", type: "string", required: false },
|
||||
{ name: "is_subscription", type: "boolean", required: false },
|
||||
{ name: "subscription", type: "string", required: false },
|
||||
{ name: "response_length", type: "long", required: false },
|
||||
@@ -86,7 +84,7 @@ const inferenceEventTable = new aws.s3tables.Table(
|
||||
},
|
||||
},
|
||||
},
|
||||
{ deleteBeforeReplace: $app.stage !== "production", ignoreChanges: ["metadata"] },
|
||||
{ deleteBeforeReplace: $app.stage !== "production" },
|
||||
)
|
||||
|
||||
export const inferenceEvent = new sst.Linkable("InferenceEvent", {
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-4RYkrGAbsrUw/n0ecPJpntSZYuV6GsmMMjK9R6MbMxU=",
|
||||
"aarch64-linux": "sha256-kwSkouFxbEYzYAsr9gaVUQrT7YfbcoKb4kB9dhNtaFM=",
|
||||
"aarch64-darwin": "sha256-mukRph5X1noBRhd5+0Ct7ZshTYxooGcoY+tZEXzfSvo=",
|
||||
"x86_64-darwin": "sha256-CE8JgBAfZQmUnunPPw4XPPw3bkso1OALmNgmyGgJr1k="
|
||||
"x86_64-linux": "sha256-LOxTad/iCquvJyonFOcz6/rDTPNDmwyBnykhWZJ5GC4=",
|
||||
"aarch64-linux": "sha256-iO+0vYhp+2x6ACmh5lQJ/2Ac4uZTqRZE/KhG3u0o6D8=",
|
||||
"aarch64-darwin": "sha256-tpBydRbrJ+4QxmkGUt/BhME8q6ysCW/CXrsNshYgqDU=",
|
||||
"x86_64-darwin": "sha256-QQcI6SK7WJ7dSkX6xZuSQPoUdwfoCaimVgoHCnrO0wY="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,6 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
writableTmpDirAsHomeHook
|
||||
];
|
||||
|
||||
postPatch = ''
|
||||
# NOTE: Relax Bun version check to be a warning instead of an error
|
||||
substituteInPlace packages/script/src/index.ts \
|
||||
--replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \
|
||||
'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}'
|
||||
'';
|
||||
|
||||
configurePhase = ''
|
||||
runHook preConfigure
|
||||
|
||||
|
||||
+7
-7
@@ -30,18 +30,18 @@
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/opentelemetry": "4.0.0-beta.83",
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
|
||||
"@effect/opentelemetry": "4.0.0-beta.74",
|
||||
"@effect/platform-node": "4.0.0-beta.74",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@types/bun": "1.3.13",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.4.2",
|
||||
"@opentui/keymap": "0.4.2",
|
||||
"@opentui/solid": "0.4.2",
|
||||
"@opentui/core": "0.3.4",
|
||||
"@opentui/keymap": "0.3.4",
|
||||
"@opentui/solid": "0.3.4",
|
||||
"@tanstack/solid-virtual": "3.13.28",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"ulid": "3.0.1",
|
||||
@@ -61,7 +61,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"effect": "4.0.0-beta.83",
|
||||
"effect": "4.0.0-beta.74",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
## Priorities
|
||||
|
||||
- Prioritise, in this order: stability, simplicity, performance.
|
||||
- Before changing session or timeline code, record a production benchmark baseline and compare it after the change.
|
||||
|
||||
## Debugging
|
||||
|
||||
- NEVER try to restart the app, or the server process, EVER.
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
- 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.
|
||||
@@ -1,79 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,144 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
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 })])
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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,
|
||||
},
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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"],
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { measureFirstNavigation } from "./first-navigation-probe"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import {
|
||||
installStressSessionTabs,
|
||||
installTimelineSettings,
|
||||
mockStressTimeline,
|
||||
stressDraftHref,
|
||||
stressSessionHref,
|
||||
} from "./timeline-test-helpers"
|
||||
import { waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
|
||||
const contentSelector = '[data-message-id], [data-component="prompt-input"]'
|
||||
const draftID = "draft_first_navigation"
|
||||
|
||||
benchmark.describe("performance: first navigation paint", () => {
|
||||
benchmark("opens an unvisited session tab without a blank frame", async ({ page, report }) => {
|
||||
await setup(page)
|
||||
const href = stressSessionHref(fixture.targetID)
|
||||
const result = await measureFirstNavigation(page, {
|
||||
href,
|
||||
destinationPath: href,
|
||||
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
|
||||
destinationSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
|
||||
contentSelector,
|
||||
navigate: async () => {
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click()
|
||||
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||
},
|
||||
})
|
||||
report(result)
|
||||
expect(result.summary.blankSamples).toBe(0)
|
||||
expect(result.summary.unknownSamples).toBe(0)
|
||||
})
|
||||
|
||||
benchmark("opens the new session page before its lazy module is used", async ({ page, report }) => {
|
||||
await setup(page, draftID)
|
||||
const href = stressDraftHref(draftID)
|
||||
const result = await measureFirstNavigation(page, {
|
||||
href,
|
||||
destinationPath: href,
|
||||
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
|
||||
destinationSelector: '[data-component="prompt-input"]',
|
||||
contentSelector,
|
||||
navigate: async () => {
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click()
|
||||
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
|
||||
},
|
||||
})
|
||||
report(result)
|
||||
expect(result.summary.blankSamples).toBe(0)
|
||||
expect(result.summary.unknownSamples).toBe(0)
|
||||
})
|
||||
|
||||
benchmark("opens a child session without a blank frame", async ({ page, report }) => {
|
||||
await setup(page)
|
||||
const href = stressSessionHref(fixture.childID)
|
||||
const result = await measureFirstNavigation(page, {
|
||||
href,
|
||||
destinationPath: href,
|
||||
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
|
||||
destinationSelector: messageSelector(fixture.expected.childMessageIDs.at(-1)!),
|
||||
contentSelector,
|
||||
navigate: async () => {
|
||||
await page.locator(`a[href="${href}"]`, { has: page.locator('[data-component="task-tool-card"]') }).click()
|
||||
await expectSessionTitle(page, fixture.expected.childTitle)
|
||||
},
|
||||
})
|
||||
report(result)
|
||||
expect(result.summary.blankSamples).toBe(0)
|
||||
expect(result.summary.unknownSamples).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
async function setup(page: Parameters<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}"]`
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
export type FirstNavigationSample = {
|
||||
observedAtMs: number
|
||||
source: boolean
|
||||
destination: boolean
|
||||
content: boolean
|
||||
pathname?: string
|
||||
center?: string
|
||||
}
|
||||
|
||||
function category(sample: FirstNavigationSample) {
|
||||
if (sample.destination && !sample.source) return "destination"
|
||||
if (sample.source && !sample.destination) return "source"
|
||||
if (!sample.content) return "blank"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
export function summarizeFirstNavigation(samples: FirstNavigationSample[]) {
|
||||
const categories = samples.map(category)
|
||||
const stable = categories.findIndex(
|
||||
(value, index) =>
|
||||
value === "destination" && categories[index + 1] === "destination" && categories[index + 2] === "destination",
|
||||
)
|
||||
return {
|
||||
samples: samples.length,
|
||||
firstDestinationObservedMs: samples[categories.indexOf("destination")]?.observedAtMs ?? null,
|
||||
stableDestinationObservedMs: stable === -1 ? null : samples[stable + 2]!.observedAtMs,
|
||||
sourceSamples: categories.filter((value) => value === "source").length,
|
||||
blankSamples: categories.filter((value) => value === "blank").length,
|
||||
unknownSamples: categories.filter((value) => value === "unknown").length,
|
||||
destinationSamples: categories.filter((value) => value === "destination").length,
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { summarizeFirstNavigation, type FirstNavigationSample } from "./first-navigation-metrics"
|
||||
|
||||
type FirstNavigationProbe = {
|
||||
samples: FirstNavigationSample[]
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
export async function measureFirstNavigation(
|
||||
page: Page,
|
||||
input: {
|
||||
href: string
|
||||
destinationPath: string
|
||||
sourceSelector: string
|
||||
destinationSelector: string
|
||||
contentSelector: string
|
||||
navigate: () => Promise<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 }
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { measureNavigationMilestones } from "./navigation-milestones"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import {
|
||||
installStressSessionTabs,
|
||||
installTimelineSettings,
|
||||
mockStressTimeline,
|
||||
stressSessionHref,
|
||||
} from "./timeline-test-helpers"
|
||||
import { waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
|
||||
const homeRow = '[data-component="home-session-row"]'
|
||||
const homeShell = '[data-component="home-session-search"]'
|
||||
|
||||
benchmark.describe("performance: home and tab navigation", () => {
|
||||
benchmark("opens a home session and paints its titlebar tab", async ({ page, report }) => {
|
||||
await setup(page, [])
|
||||
await page.goto("/")
|
||||
const row = page.locator(homeRow).filter({ hasText: fixture.expected.targetTitle }).first()
|
||||
await expect(row).toBeVisible()
|
||||
const href = stressSessionHref(fixture.targetID)
|
||||
const result = await measureNavigationMilestones(page, {
|
||||
triggerSelector: homeRow,
|
||||
milestones: {
|
||||
content: { selector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!) },
|
||||
tab: { selector: `[data-slot="titlebar-tabs"] a[href="${href}"]` },
|
||||
},
|
||||
navigate: async () => {
|
||||
await row.click()
|
||||
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||
},
|
||||
})
|
||||
report(result)
|
||||
await expect(page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`)).toContainText(
|
||||
fixture.expected.targetTitle,
|
||||
)
|
||||
})
|
||||
|
||||
benchmark("stages the review body after cold session content", async ({ page, report }) => {
|
||||
await setup(page, [])
|
||||
await page.goto("/")
|
||||
const row = page.locator(homeRow).filter({ hasText: fixture.expected.targetTitle }).first()
|
||||
await expect(row).toBeVisible()
|
||||
const result = await page.evaluate(
|
||||
({ rowSelector, title, contentSelector }) =>
|
||||
new Promise<{ contentBeforeReview: boolean; samples: number }>((resolve) => {
|
||||
let samples = 0
|
||||
const sample = () => {
|
||||
samples++
|
||||
const content = !!document.querySelector(contentSelector)
|
||||
const review = !!document.querySelector('[data-component="session-review"]')
|
||||
if (content && !review) {
|
||||
resolve({ contentBeforeReview: true, samples })
|
||||
return
|
||||
}
|
||||
if (content && review) {
|
||||
resolve({ contentBeforeReview: false, samples })
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}
|
||||
const target = [...document.querySelectorAll<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}"]`
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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] })
|
||||
})
|
||||
@@ -1,251 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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)
|
||||
)
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,487 +0,0 @@
|
||||
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" },
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
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 })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,547 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,369 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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"}`
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { summarizeFirstNavigation } from "../timeline/first-navigation-metrics"
|
||||
|
||||
test("reports blank frames before first destination and stable paint", () => {
|
||||
expect(
|
||||
summarizeFirstNavigation([
|
||||
{ observedAtMs: 16, source: true, destination: false, content: true },
|
||||
{ observedAtMs: 32, source: false, destination: false, content: false },
|
||||
{ observedAtMs: 48, source: false, destination: true, content: true },
|
||||
{ observedAtMs: 64, source: false, destination: true, content: true },
|
||||
{ observedAtMs: 80, source: false, destination: true, content: true },
|
||||
]),
|
||||
).toEqual({
|
||||
samples: 5,
|
||||
firstDestinationObservedMs: 48,
|
||||
stableDestinationObservedMs: 80,
|
||||
sourceSamples: 1,
|
||||
blankSamples: 1,
|
||||
unknownSamples: 0,
|
||||
destinationSamples: 3,
|
||||
})
|
||||
})
|
||||
|
||||
test("does not report stability for interrupted destination frames", () => {
|
||||
expect(
|
||||
summarizeFirstNavigation([
|
||||
{ observedAtMs: 16, source: false, destination: true, content: true },
|
||||
{ observedAtMs: 32, source: false, destination: false, content: true },
|
||||
{ observedAtMs: 48, source: false, destination: true, content: true },
|
||||
]).stableDestinationObservedMs,
|
||||
).toBeNull()
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { summarizeNavigationMilestones } from "../timeline/navigation-milestones"
|
||||
|
||||
test("reports first and stable paint for each navigation milestone", () => {
|
||||
expect(
|
||||
summarizeNavigationMilestones([
|
||||
{ observedAtMs: 16, milestones: { content: false, tab: false } },
|
||||
{ observedAtMs: 32, milestones: { content: true, tab: false } },
|
||||
{ observedAtMs: 48, milestones: { content: true, tab: true } },
|
||||
{ observedAtMs: 64, milestones: { content: true, tab: true } },
|
||||
{ observedAtMs: 80, milestones: { content: true, tab: true } },
|
||||
]),
|
||||
).toEqual({
|
||||
samples: 5,
|
||||
milestones: {
|
||||
content: { firstObservedMs: 32, stableObservedMs: 64 },
|
||||
tab: { firstObservedMs: 48, stableObservedMs: 80 },
|
||||
},
|
||||
all: { firstObservedMs: 48, stableObservedMs: 80 },
|
||||
})
|
||||
})
|
||||
|
||||
test("reports missing stability when a milestone appears in the final samples", () => {
|
||||
expect(
|
||||
summarizeNavigationMilestones([
|
||||
{ observedAtMs: 16, milestones: { content: false } },
|
||||
{ observedAtMs: 32, milestones: { content: true } },
|
||||
]),
|
||||
).toEqual({
|
||||
samples: 2,
|
||||
milestones: { content: { firstObservedMs: 32, stableObservedMs: null } },
|
||||
all: { firstObservedMs: 32, stableObservedMs: null },
|
||||
})
|
||||
})
|
||||
@@ -1,42 +0,0 @@
|
||||
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 })
|
||||
})
|
||||
@@ -1,54 +0,0 @@
|
||||
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()
|
||||
})
|
||||
@@ -1,14 +0,0 @@
|
||||
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" })
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
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)
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { fixture } from "../timeline/session-timeline-stress.fixture"
|
||||
import { stressSessionHref } from "../timeline/timeline-test-helpers"
|
||||
|
||||
test("builds stress session links for the benchmark server", () => {
|
||||
expect(stressSessionHref(fixture.sourceID)).toBe(
|
||||
`/server/${base64Encode("http://127.0.0.1:4096")}/session/${fixture.sourceID}`,
|
||||
)
|
||||
})
|
||||
@@ -1,135 +0,0 @@
|
||||
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" })
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/ReviewLineCommentRegression"
|
||||
const sessionID = "ses_review_line_comment_regression"
|
||||
const title = "Review line comment regression"
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await openReview(page)
|
||||
})
|
||||
|
||||
test("opens the comment editor when code is clicked", async ({ page }) => {
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
const line = review.getByText("export const value = 'after'", { exact: true })
|
||||
await expectAppVisible(line)
|
||||
await line.click()
|
||||
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
})
|
||||
|
||||
test("opens the comment editor when a line number is clicked", async ({ page }) => {
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
const lineNumber = review.locator('[data-column-number="1"]').last()
|
||||
await expectAppVisible(lineNumber)
|
||||
await lineNumber.click()
|
||||
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
})
|
||||
|
||||
test("opens the comment editor for a line number range", async ({ page }) => {
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
const start = review.locator('[data-column-number="1"]').last()
|
||||
const end = review.locator('[data-column-number="3"]').last()
|
||||
await expectAppVisible(start)
|
||||
await expectAppVisible(end)
|
||||
|
||||
const from = await start.boundingBox()
|
||||
const to = await end.boundingBox()
|
||||
if (!from || !to) throw new Error("Missing line number bounds")
|
||||
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2)
|
||||
await page.mouse.up()
|
||||
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
})
|
||||
|
||||
test("shows a comment button when a line number is hovered", async ({ page }) => {
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
const lineNumber = review.locator('[data-column-number="1"]').last()
|
||||
await expectAppVisible(lineNumber)
|
||||
|
||||
const comment = review.getByRole("button", { name: "Comment", exact: true })
|
||||
await expect(async () => {
|
||||
await page.mouse.move(0, 0)
|
||||
await lineNumber.hover()
|
||||
await expect(comment).toBeVisible({ timeout: 500 })
|
||||
await comment.click({ timeout: 500 })
|
||||
}).toPass()
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
})
|
||||
|
||||
test("stages a submitted line comment in the prompt context", async ({ page }) => {
|
||||
const requests: string[] = []
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "GET") requests.push(`${request.method()} ${new URL(request.url()).pathname}`)
|
||||
})
|
||||
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
await review.getByText("export const value = 'after'", { exact: true }).click()
|
||||
await review.getByRole("textbox").fill("Use the existing value instead")
|
||||
await review.locator('[data-slot="line-comment-action"][data-variant="primary"]').click()
|
||||
|
||||
await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible()
|
||||
await page.getByRole("tab", { name: "Session" }).click()
|
||||
const context = page.getByText("Use the existing value instead", { exact: true }).last()
|
||||
await expect(context).toBeVisible()
|
||||
await expect(context.locator("..")).toContainText("review.ts:2")
|
||||
expect(requests).toEqual([])
|
||||
})
|
||||
|
||||
async function openReview(page: Page) {
|
||||
await page.setViewportSize({ width: 700, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
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()
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/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 } }))
|
||||
})
|
||||
}
|
||||
@@ -261,6 +261,7 @@ async function configurePage(page: Page) {
|
||||
editToolPartsExpanded: true,
|
||||
shellToolPartsExpanded: true,
|
||||
showReasoningSummaries: true,
|
||||
showSessionProgressBar: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -47,6 +47,7 @@ async function configurePage(page: Page) {
|
||||
editToolPartsExpanded: true,
|
||||
shellToolPartsExpanded: true,
|
||||
showReasoningSummaries: true,
|
||||
showSessionProgressBar: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/TodoDockNavigation"
|
||||
const projectID = "proj_todo_dock_navigation"
|
||||
const sourceID = "ses_todo_dock_source"
|
||||
const otherID = "ses_todo_dock_other"
|
||||
const sourceTitle = "Todo dock animation"
|
||||
const otherTitle = "Separate session"
|
||||
|
||||
const activeTodos = [
|
||||
{ id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
|
||||
{ id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
|
||||
{ id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
|
||||
]
|
||||
|
||||
type EventPayload = {
|
||||
directory: string
|
||||
payload: Record<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,7 +21,6 @@ const words = [
|
||||
"vector",
|
||||
]
|
||||
|
||||
const serverKey = "http://127.0.0.1:4096"
|
||||
const sourceID = "ses_smoke_source"
|
||||
const targetID = "ses_smoke_target"
|
||||
const directory = "C:/OpenCode/SmokeProject"
|
||||
@@ -140,7 +139,7 @@ function toolPart(
|
||||
status: "completed",
|
||||
input,
|
||||
output: lorem(index * 23 + partIndex, outputLength),
|
||||
title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed",
|
||||
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
|
||||
metadata,
|
||||
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
|
||||
},
|
||||
@@ -201,7 +200,9 @@ function turn(index: number): Message[] {
|
||||
...(index % 8 === 0
|
||||
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
: []),
|
||||
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
|
||||
...(index % 7 === 0
|
||||
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
|
||||
: []),
|
||||
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
|
||||
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
|
||||
...(index % 13 === 0
|
||||
@@ -241,7 +242,6 @@ function orderedParts(message: Message) {
|
||||
|
||||
export const fixture = {
|
||||
directory,
|
||||
serverKey,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
@@ -295,7 +295,6 @@ export const fixture = {
|
||||
.filter(renderable)
|
||||
.map((part) => part.id),
|
||||
),
|
||||
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -58,18 +58,7 @@ test.describe("smoke: session timeline", () => {
|
||||
await page.mouse.wheel(0, -120)
|
||||
await page.waitForTimeout(20)
|
||||
}
|
||||
const keys = await scroller.evaluate((element) => {
|
||||
const view = element.getBoundingClientRect()
|
||||
return [...element.querySelectorAll<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 keys = ["prt_user_text_smoke_0032", "prt_text_2_smoke_0032", "prt_tool_apply_patch_8_smoke_0032"]
|
||||
const positions = () =>
|
||||
scroller.evaluate((element, keys) => {
|
||||
const top = element.getBoundingClientRect().top
|
||||
@@ -338,18 +327,6 @@ test.describe("smoke: session timeline", () => {
|
||||
const expectedMessageIDs = fixture.expected.targetMessageIDs
|
||||
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
|
||||
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
|
||||
|
||||
const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`)
|
||||
const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
|
||||
const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
|
||||
await expect(shellSubtitle).toHaveCount(0)
|
||||
await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck")
|
||||
await shellTrigger.click()
|
||||
await expect(shellTrigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(shellSubtitle).toHaveText("bun typecheck")
|
||||
await shellTrigger.click()
|
||||
await expect(shellTrigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(shellSubtitle).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -362,6 +339,7 @@ async function configureSmokePage(page: Page, directory: string) {
|
||||
editToolPartsExpanded: true,
|
||||
shellToolPartsExpanded: true,
|
||||
showReasoningSummaries: true,
|
||||
showSessionProgressBar: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -728,7 +706,7 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin
|
||||
}
|
||||
|
||||
async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
|
||||
const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`
|
||||
const href = `/${base64Encode(fixture.directory)}/session/${sessionID}`
|
||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Page, Route } from "@playwright/test"
|
||||
|
||||
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
|
||||
const emptyList = new Set([
|
||||
"/skill",
|
||||
"/command",
|
||||
"/lsp",
|
||||
"/formatter",
|
||||
"/permission",
|
||||
"/question",
|
||||
"/vcs/status",
|
||||
"/vcs/diff",
|
||||
])
|
||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"])
|
||||
|
||||
export interface MockServerConfig {
|
||||
@@ -9,19 +18,13 @@ export interface MockServerConfig {
|
||||
project: unknown
|
||||
sessions: ({ id: string } & Record<string, unknown>)[]
|
||||
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[]
|
||||
eventRetry?: number
|
||||
todos?: (sessionID: string) => unknown[]
|
||||
permissions?: unknown[] | (() => unknown[])
|
||||
questions?: unknown[] | (() => unknown[])
|
||||
}
|
||||
|
||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const cursors = new Map<string, string>()
|
||||
let nextCursor = 0
|
||||
const staticRoutes: Record<string, unknown> = {
|
||||
"/provider": config.provider,
|
||||
"/path": {
|
||||
@@ -41,19 +44,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
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()
|
||||
if (url.port !== targetPort) return route.fallback()
|
||||
|
||||
const path = url.pathname
|
||||
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
||||
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 (emptyList.has(path)) return json(route, [])
|
||||
if (path in staticRoutes) return json(route, staticRoutes[path])
|
||||
@@ -64,34 +59,26 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
return json(route, session ?? {})
|
||||
}
|
||||
|
||||
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
|
||||
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
|
||||
|
||||
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||
if (messagesMatch) {
|
||||
const token = url.searchParams.get("before") ?? undefined
|
||||
const before = token ? cursors.get(token) : undefined
|
||||
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
||||
const before = url.searchParams.get("before") ?? undefined
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
|
||||
if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const limit = Number(url.searchParams.get("limit") ?? 80)
|
||||
const pageData = config.pageMessages(messagesMatch[1], limit, before)
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
|
||||
if (!pageData.cursor) return json(route, pageData.items)
|
||||
const cursor = `cursor_${++nextCursor}`
|
||||
cursors.set(cursor, pageData.cursor)
|
||||
return json(route, pageData.items, { "x-next-cursor": cursor })
|
||||
return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined)
|
||||
}
|
||||
|
||||
if (url.port === targetPort && targetPort !== appPort) return json(route, {})
|
||||
return route.fallback()
|
||||
return json(route, {})
|
||||
})
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
|
||||
function json(route: Route, body: unknown, headers?: Record<string, string>) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: {
|
||||
"access-control-allow-origin": "*",
|
||||
|
||||
+5
-11
@@ -1,28 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en" style="background-color: var(--v2-background-bg-deep, #fafafa)">
|
||||
<html lang="en" style="background-color: var(--background-base)">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, interactive-widget=resizes-content, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
|
||||
<title>OpenCode</title>
|
||||
<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="shortcut icon" href="/favicon-v3.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v3.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<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 name="theme-color" content="#F8F7F7" />
|
||||
<meta property="og: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>
|
||||
</head>
|
||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden bg-v2-background-bg-deep">
|
||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
|
||||
<div id="root" class="flex flex-col h-dvh p-px"></div>
|
||||
<script src="/src/entry.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.10",
|
||||
"version": "1.17.7",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -17,15 +17,14 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"test": "bun run test:unit && bun run test:browser",
|
||||
"test": "bun run test:unit && bun run test:virtualizer",
|
||||
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
|
||||
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
|
||||
"test:virtualizer": "bun test --conditions=browser --preload ./happydom.ts ./test-browser/solid-virtual.test.ts",
|
||||
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:local": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:report": "playwright show-report e2e/playwright-report",
|
||||
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts"
|
||||
"test:e2e:report": "playwright show-report e2e/playwright-report"
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
@@ -47,7 +46,6 @@
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
"@sentry/solid": "catalog:",
|
||||
|
||||
@@ -9,7 +9,6 @@ const reuse = !process.env.CI
|
||||
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testIgnore: process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
|
||||
outputDir: "./e2e/test-results",
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
|
||||
@@ -15,11 +15,10 @@
|
||||
|
||||
document.documentElement.dataset.theme = themeId
|
||||
document.documentElement.dataset.colorScheme = mode
|
||||
document.documentElement.style.backgroundColor = isDark ? "#080808" : "#fafafa"
|
||||
|
||||
// Update theme-color meta tag to match app color scheme
|
||||
var metas = document.querySelectorAll("meta[name='theme-color']")
|
||||
if (metas.length > 0) metas[0].setAttribute("content", isDark ? "#080808" : "#fafafa")
|
||||
if (metas.length > 0) metas[0].setAttribute("content", isDark ? "#131010" : "#F8F7F7")
|
||||
|
||||
if (themeId === "oc-2") return
|
||||
|
||||
|
||||
+111
-245
@@ -4,7 +4,7 @@ import { I18nProvider } from "@opencode-ai/ui/context"
|
||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||
import { File } from "@opencode-ai/session-ui/file"
|
||||
import { File } from "@opencode-ai/ui/file"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
@@ -30,8 +30,8 @@ import { Dynamic } from "solid-js/web"
|
||||
import { CommandProvider } from "@/context/command"
|
||||
import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
|
||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider } from "@/context/server-sync"
|
||||
import { GlobalProvider } from "@/context/global"
|
||||
import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||
@@ -47,155 +47,77 @@ import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
import { WslServersProvider } from "@/wsl/context"
|
||||
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||
import LegacyLayout from "@/pages/layout"
|
||||
import NewLayout from "@/pages/layout-new"
|
||||
import Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import {
|
||||
legacySessionHref,
|
||||
legacySessionServer,
|
||||
requireServerKey,
|
||||
selectSessionLineage,
|
||||
sessionHref,
|
||||
} from "./utils/session-route"
|
||||
|
||||
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 SessionRoute = () => {
|
||||
const settings = useSettings()
|
||||
const params = useParams()
|
||||
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
const sdk = useSDK()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const SessionRoute = Object.assign(
|
||||
() => {
|
||||
const settings = useSettings()
|
||||
const params = useParams()
|
||||
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
const sdk = useSDK()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
|
||||
if (params.id && settings.general.newLayoutDesigns()) {
|
||||
const sessionID = params.id
|
||||
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, sync: sync() }
|
||||
},
|
||||
({ id, sync }) => sync.session.lineage.resolve(id),
|
||||
)
|
||||
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,
|
||||
// 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 (
|
||||
<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>
|
||||
return (
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
</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) {
|
||||
function SelectedServerLayout(props: ParentProps) {
|
||||
return (
|
||||
<ServerKey>
|
||||
<ServerSDKProvider>
|
||||
<ServerSyncProvider>{props.children}</ServerSyncProvider>
|
||||
<ServerSyncProvider>
|
||||
<ServerScopedShell>{props.children}</ServerScopedShell>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</ServerKey>
|
||||
)
|
||||
}
|
||||
|
||||
function LegacyServerLayout(props: ParentProps) {
|
||||
// Wraps /new-session. It resolves the draft's target server and provides the
|
||||
// server-scoped shell for that server — without ServerKey, so the page never depends
|
||||
// on the globally "selected" server.
|
||||
function DraftServerLayout(props: ParentProps) {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const conn = createMemo(() => {
|
||||
const id = search.draftId
|
||||
if (!id) return undefined
|
||||
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id)
|
||||
if (!draft) return undefined
|
||||
return server.list.find((c) => ServerConnection.key(c) === draft.server)
|
||||
})
|
||||
|
||||
return (
|
||||
<SelectedServerProviders>
|
||||
<LegacyServerScopedShell>{props.children}</LegacyServerScopedShell>
|
||||
</SelectedServerProviders>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<ServerScopedShell>{props.children}</ServerScopedShell>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -204,37 +126,37 @@ function DraftRoute() {
|
||||
const tabs = useTabs()
|
||||
return (
|
||||
<Show when={tabs.ready()}>
|
||||
<Show
|
||||
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
|
||||
keyed
|
||||
fallback={<Navigate href="/" />}
|
||||
>
|
||||
{(draft) => <ResolvedDraftRoute draft={draft} />}
|
||||
<Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
|
||||
{(draftID) => <ResolvedDraftRoute draftID={draftID} />}
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
const server = useServer()
|
||||
const conn = createMemo(() => server.list.find((item) => ServerConnection.key(item) === props.draft.server))
|
||||
const directory = () => props.draft.directory
|
||||
const serverKey = () => props.draft.server
|
||||
function ResolvedDraftRoute(props: { draftID: string }) {
|
||||
const tabs = useTabs()
|
||||
const draft = createMemo(() =>
|
||||
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
|
||||
)
|
||||
|
||||
// Key on the directory so retargeting the draft's project re-instantiates the
|
||||
// directory-scoped providers while keeping the same draft id. The draft's target
|
||||
// server is provided by DraftServerLayout, so changing only the server updates the
|
||||
// SDK/sync hooks without remounting the composer.
|
||||
const directory = () => draft()?.directory
|
||||
|
||||
return (
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<TargetServerScopedProviders directory={directory}>
|
||||
<SDKProvider directory={directory}>
|
||||
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||
<DraftProviders>
|
||||
<NewSession />
|
||||
</DraftProviders>
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</TargetServerScopedProviders>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
<Show when={directory()} keyed>
|
||||
{(dir) => (
|
||||
<SDKProvider directory={dir}>
|
||||
<DirectoryDataProvider directory={dir} draftID={props.draftID}>
|
||||
<DraftProviders>
|
||||
<NewSession />
|
||||
</DraftProviders>
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -288,61 +210,32 @@ function BodyDesignClass() {
|
||||
// shell (router root) so they stay mounted regardless of the active server/route.
|
||||
function SharedProviders(props: ParentProps) {
|
||||
return (
|
||||
<>
|
||||
<SettingsProvider>
|
||||
<BodyDesignClass />
|
||||
<CommandProvider>
|
||||
<HighlightsProvider>{props.children}</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</>
|
||||
</SettingsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
||||
type ServerScopedShellProps = ParentProps<{
|
||||
directory?: () => string | undefined
|
||||
sessionID?: () => string | undefined
|
||||
}>
|
||||
|
||||
function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||
// Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside
|
||||
// each per-route server layout so they resolve to that route's server (selected vs
|
||||
// draft). The Layout remounts when crossing between those groups.
|
||||
function ServerScopedShell(props: ParentProps) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<PermissionProvider>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
<NotificationProvider>
|
||||
<ModelsProvider>
|
||||
<Layout>{props.children}</Layout>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionProviders(props: ParentProps) {
|
||||
return (
|
||||
<TerminalProvider>
|
||||
@@ -525,9 +418,11 @@ export function AppInterface(props: {
|
||||
router?: Component<BaseRouterProps>
|
||||
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.
|
||||
// The shared shell holds only server-agnostic providers (QueryClient + Settings/
|
||||
// Command/Highlights) and stays mounted across every route. The server-scoped
|
||||
// providers and the visual Layout live in the per-route layouts below, so they
|
||||
// resolve to that route's server (selected for most routes, the draft's server for
|
||||
// /new-session). appChildren is server-agnostic, so it renders here once.
|
||||
const ServerShell = (shellProps: ParentProps) => (
|
||||
<QueryProvider>
|
||||
<SharedProviders>
|
||||
@@ -544,57 +439,28 @@ export function AppInterface(props: {
|
||||
servers={props.servers}
|
||||
>
|
||||
<GlobalProvider>
|
||||
<SettingsProvider>
|
||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
|
||||
<Dynamic
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => (
|
||||
<TabsProvider>
|
||||
<ServerShell>
|
||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
||||
</Show>
|
||||
</ServerShell>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
<Routes />
|
||||
</Dynamic>
|
||||
</Show>
|
||||
</ConnectionGate>
|
||||
</SettingsProvider>
|
||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||
<Dynamic
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => (
|
||||
<TabsProvider>
|
||||
<ServerShell>{routerProps.children}</ServerShell>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
<Route component={SelectedServerLayout}>
|
||||
<Route path="/" component={HomeRoute} />
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route component={DraftServerLayout}>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
</Route>
|
||||
</Dynamic>
|
||||
</ConnectionGate>
|
||||
</GlobalProvider>
|
||||
</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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -363,7 +363,7 @@ export function DebugBar() {
|
||||
return (
|
||||
<aside
|
||||
aria-label={language.t("debugBar.ariaLabel")}
|
||||
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]"
|
||||
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]"
|
||||
>
|
||||
<div class="grid grid-cols-5 gap-px font-mono">
|
||||
<Cell
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { type Accessor, createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Link } from "@/components/link"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
@@ -17,16 +17,16 @@ import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
|
||||
export function DialogConnectProvider(props: { provider: string; directory?: Accessor<string | undefined> }) {
|
||||
export function DialogConnectProvider(props: { provider: string }) {
|
||||
const dialog = useDialog()
|
||||
const serverSync = useServerSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const language = useLanguage()
|
||||
const providers = useProviders(props.directory)
|
||||
const providers = useProviders()
|
||||
|
||||
const all = () => {
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider directory={props.directory} />)
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { type Accessor, batch, For } from "solid-js"
|
||||
import { batch, For } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Link } from "@/components/link"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
@@ -17,7 +17,6 @@ import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
|
||||
type Props = {
|
||||
back?: "providers" | "close"
|
||||
directory?: Accessor<string | undefined>
|
||||
}
|
||||
|
||||
export function DialogCustomProvider(props: Props) {
|
||||
@@ -41,7 +40,7 @@ export function DialogCustomProvider(props: Props) {
|
||||
dialog.close()
|
||||
return
|
||||
}
|
||||
dialog.show(() => <DialogSelectProvider directory={props.directory} />)
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
}
|
||||
|
||||
const addModel = () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||
const dialog = useDialog()
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
||||
const serverCtx = createMemo(() => global.createServerCtx(props.server))
|
||||
const serverSDK = () => serverCtx().sdk
|
||||
const serverSync = () => serverCtx().sync
|
||||
|
||||
|
||||
@@ -9,16 +9,14 @@ import { popularProviders } from "@/hooks/use-providers"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
export const DialogManageModels: Component = () => {
|
||||
const local = useLocal()
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const directory = () => decode64(local.slug())
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
dialog.show(() => <DialogSelectProvider directory={directory} />)
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
}
|
||||
const providerRank = (id: string) => popularProviders.indexOf(id)
|
||||
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
pickerMode,
|
||||
preloadTreeDirectories,
|
||||
cleanPickerInput,
|
||||
createPriorityTaskQueue,
|
||||
createDirectorySearch,
|
||||
currentPickerSuggestions,
|
||||
displayPickerPath,
|
||||
@@ -39,7 +38,7 @@ interface DialogSelectDirectoryV2Props {
|
||||
|
||||
export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
const global = useGlobal()
|
||||
const { sync, sdk } = global.ensureServerCtx(props.server)
|
||||
const { sync, sdk } = global.createServerCtx(props.server)
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const policy = pickerMode(props.mode ?? "directory", props.start)
|
||||
@@ -56,7 +55,6 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
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
|
||||
@@ -104,21 +102,16 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
})
|
||||
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input()))
|
||||
|
||||
async function load(path: string, generation: number, eager = false) {
|
||||
async function load(path: string, generation: number, preload = true) {
|
||||
const key = path.replace(/\/+$/, "")
|
||||
setError(false)
|
||||
const absolute = absoluteTreePath(root(), key)
|
||||
const existing = listings.get(key)
|
||||
if (existing && !eager) loads.promote(`${generation}:${key}`)
|
||||
const request =
|
||||
existing ??
|
||||
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||
return sdk.client.file
|
||||
.list({ directory: absolute, path: "" })
|
||||
.then((result) => result.data ?? [])
|
||||
.catch(() => undefined)
|
||||
})
|
||||
listings.get(key) ??
|
||||
sdk.client.file
|
||||
.list({ directory: absolute, path: "" })
|
||||
.then((result) => result.data ?? [])
|
||||
.catch(() => undefined)
|
||||
listings.set(key, request)
|
||||
const nodes = await request
|
||||
if (!activeTreeNavigation(generation, navigation)) return false
|
||||
@@ -128,8 +121,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
return false
|
||||
}
|
||||
tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item })))
|
||||
if (!eager && advanceTreePreload(advanced, key)) {
|
||||
for (const directory of preloadTreeDirectories(key, nodes)) void load(directory, generation, true)
|
||||
if (preload && advanceTreePreload(advanced, key)) {
|
||||
void Promise.all(preloadTreeDirectories(key, nodes).map((directory) => load(directory, generation, false)))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ function uniqueRows(rows: Row[]) {
|
||||
|
||||
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
const global = useGlobal()
|
||||
const { sync, sdk, ...serverCtx } = global.ensureServerCtx(props.server)
|
||||
const { sync, sdk, ...serverCtx } = global.createServerCtx(props.server)
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
|
||||
@@ -10,27 +10,24 @@ import { useLocal } from "@/context/local"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
|
||||
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
|
||||
const local = useLocal()
|
||||
const model = props.model ?? local.model
|
||||
const model = props.model ?? useLocal().model
|
||||
const dialog = useDialog()
|
||||
const directory = () => decode64(local.slug())
|
||||
const providers = useProviders(directory)
|
||||
const providers = useProviders()
|
||||
const language = useLanguage()
|
||||
|
||||
const connect = (provider: string) => {
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogConnectProvider provider={provider} directory={directory} />)
|
||||
dialog.show(() => <x.DialogConnectProvider provider={provider} />)
|
||||
})
|
||||
}
|
||||
|
||||
const all = () => {
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import { List } from "@opencode-ai/ui/list"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||
provider === "opencode" && (!cost || cost.input === 0)
|
||||
@@ -105,8 +104,6 @@ export function ModelSelectorPopover(props: {
|
||||
dismiss: null,
|
||||
})
|
||||
const dialog = useDialog()
|
||||
const local = useLocal()
|
||||
const directory = () => decode64(local.slug())
|
||||
|
||||
const close = (dismiss: Dismiss) => {
|
||||
setStore("dismiss", dismiss)
|
||||
@@ -123,7 +120,7 @@ export function ModelSelectorPopover(props: {
|
||||
const handleConnectProvider = () => {
|
||||
close("provider")
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
}
|
||||
const language = useLanguage()
|
||||
@@ -202,12 +199,10 @@ export function ModelSelectorPopover(props: {
|
||||
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const local = useLocal()
|
||||
const directory = () => decode64(local.slug())
|
||||
|
||||
const provider = () => {
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Accessor, Component, Show } from "solid-js"
|
||||
import { Component, Show } from "solid-js"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
@@ -11,9 +11,9 @@ import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||
|
||||
const CUSTOM_ID = "_custom"
|
||||
|
||||
export const DialogSelectProvider: Component<{ directory?: Accessor<string | undefined> }> = (props) => {
|
||||
export const DialogSelectProvider: Component = () => {
|
||||
const dialog = useDialog()
|
||||
const providers = useProviders(props.directory)
|
||||
const providers = useProviders()
|
||||
const language = useLanguage()
|
||||
|
||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||
@@ -56,10 +56,10 @@ export const DialogSelectProvider: Component<{ directory?: Accessor<string | und
|
||||
onSelect={(x) => {
|
||||
if (!x) return
|
||||
if (x.id === CUSTOM_ID) {
|
||||
dialog.show(() => <DialogCustomProvider back="providers" directory={props.directory} />)
|
||||
dialog.show(() => <DialogCustomProvider back="providers" />)
|
||||
return
|
||||
}
|
||||
dialog.show(() => <DialogConnectProvider provider={x.id} directory={props.directory} />)
|
||||
dialog.show(() => <DialogConnectProvider provider={x.id} />)
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
treePathWithin,
|
||||
currentPickerSuggestions,
|
||||
createDirectorySearch,
|
||||
createPriorityTaskQueue,
|
||||
displayPickerPath,
|
||||
pickerParent,
|
||||
pickerRoot,
|
||||
@@ -169,41 +168,6 @@ test("advances preloading once for every expanded directory", () => {
|
||||
expect(advanceTreePreload(advanced, "repos/")).toBeTrue()
|
||||
})
|
||||
|
||||
test("limits background tasks and prioritizes newly requested work", async () => {
|
||||
const queue = createPriorityTaskQueue<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)
|
||||
|
||||
@@ -138,79 +138,6 @@ 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))
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export function HelpButton() {
|
||||
|
||||
return (
|
||||
<Show when={!state.dismissed}>
|
||||
<div class="fixed bottom-4 right-4 z-50 hidden md:block">
|
||||
<div class="fixed bottom-4 right-4 z-50">
|
||||
<Popover
|
||||
open={shown()}
|
||||
onOpenChange={setShown}
|
||||
|
||||
@@ -1,27 +1,11 @@
|
||||
// @ts-nocheck
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||
import { createPromptState } from "@/context/prompt"
|
||||
import { SessionComposerRegion } from "@/pages/session/composer"
|
||||
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
||||
|
||||
function createPromptInputStoryRuntime() {
|
||||
const state = createPromptState()
|
||||
return {
|
||||
state,
|
||||
history: createPromptInputHistory(),
|
||||
submission: {
|
||||
abort() {},
|
||||
handleSubmit(event: Event) {
|
||||
event.preventDefault()
|
||||
state.reset()
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function PromptInputExample() {
|
||||
const input = createPromptInputStoryRuntime()
|
||||
const state = createPromptState()
|
||||
const history = createPromptInputHistory()
|
||||
const [controls, setControls] = createStore({
|
||||
agent: "build",
|
||||
variant: undefined as string | undefined,
|
||||
@@ -38,6 +22,13 @@ function PromptInputExample() {
|
||||
set: (variant?: string) => setControls("variant", variant),
|
||||
},
|
||||
}
|
||||
const submission = {
|
||||
abort() {},
|
||||
handleSubmit(event: Event) {
|
||||
event.preventDefault()
|
||||
state.reset()
|
||||
},
|
||||
}
|
||||
const inputControls = {
|
||||
agents: {
|
||||
available: [{ name: "review", hidden: false, mode: "subagent" }],
|
||||
@@ -78,7 +69,7 @@ function PromptInputExample() {
|
||||
const addReviewComment = () => {
|
||||
const comment = controls.comments + 1
|
||||
setControls("comments", comment)
|
||||
input.state.context.add({
|
||||
state.context.add({
|
||||
type: "file",
|
||||
path: "src/components/prompt-input.tsx",
|
||||
selection: {
|
||||
@@ -96,7 +87,7 @@ function PromptInputExample() {
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-3">
|
||||
<PromptInput controls={inputControls} {...input} />
|
||||
<PromptInput controls={inputControls} state={state} history={history} submission={submission} />
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -110,82 +101,6 @@ function PromptInputExample() {
|
||||
)
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -200,12 +115,3 @@ export const Basic = {
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const DockAlreadyOpen = {
|
||||
render: () => (
|
||||
<div class="pt-10">
|
||||
<h1 class="mb-4">Prompt Input with open Todo dock</h1>
|
||||
<PromptInputWithOpenDock />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -67,7 +67,6 @@ import { PromptContextItems } from "./prompt-input/context-items"
|
||||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
||||
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
||||
import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
@@ -347,10 +346,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||
)
|
||||
|
||||
const [store, setStore] = createPromptInputTransientState(
|
||||
() => prompt.capture(),
|
||||
Math.floor(Math.random() * EXAMPLES.length),
|
||||
)
|
||||
const [store, setStore] = createStore<{
|
||||
popover: "at" | "slash" | null
|
||||
historyIndex: number
|
||||
savedPrompt: PromptHistoryEntry | null
|
||||
placeholder: number
|
||||
draggingType: "image" | "@mention" | null
|
||||
mode: "normal" | "shell"
|
||||
applyingHistory: boolean
|
||||
variantOpen: boolean
|
||||
}>({
|
||||
popover: null,
|
||||
historyIndex: -1,
|
||||
savedPrompt: null as PromptHistoryEntry | null,
|
||||
placeholder: Math.floor(Math.random() * EXAMPLES.length),
|
||||
draggingType: null,
|
||||
mode: "normal",
|
||||
applyingHistory: false,
|
||||
variantOpen: false,
|
||||
})
|
||||
const [picker, setPicker] = createStore({
|
||||
projectOpen: false,
|
||||
projectSearch: "",
|
||||
@@ -1365,7 +1379,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
|
||||
|
||||
const [promptReady] = createResource(
|
||||
() => prompt.ready.promise,
|
||||
() => prompt.ready().promise,
|
||||
(p) => p,
|
||||
)
|
||||
|
||||
|
||||
@@ -25,19 +25,6 @@ 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 = {
|
||||
prompt: ReturnType<typeof usePrompt>
|
||||
editor: () => HTMLDivElement | undefined
|
||||
@@ -49,22 +36,27 @@ type PromptAttachmentsInput = {
|
||||
getPathForFile?: (file: File) => string
|
||||
}
|
||||
|
||||
export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
|
||||
const capture = (): AttachmentTarget | undefined => {
|
||||
const prompt = input.capture()
|
||||
const editor = input.editor()
|
||||
if (!editor) return
|
||||
return { prompt, cursor: prompt.cursor() ?? getCursorPosition(editor) }
|
||||
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
const prompt = input.prompt
|
||||
const language = useLanguage()
|
||||
|
||||
const warn = () => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.pasteUnsupported.title"),
|
||||
description: language.t("prompt.toast.pasteUnsupported.description"),
|
||||
})
|
||||
}
|
||||
|
||||
const add = async (file: File, toast = true, target = capture()) => {
|
||||
if (!target) return false
|
||||
const add = async (file: File, toast = true) => {
|
||||
const mime = await attachmentMime(file)
|
||||
if (!mime) {
|
||||
if (toast) input.warn?.()
|
||||
if (toast) warn()
|
||||
return false
|
||||
}
|
||||
|
||||
const editor = input.editor()
|
||||
if (!editor) return false
|
||||
|
||||
const url = await dataUrl(file, mime)
|
||||
if (!url) return false
|
||||
|
||||
@@ -76,42 +68,34 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
|
||||
mime,
|
||||
dataUrl: url,
|
||||
}
|
||||
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
|
||||
const cursor = prompt.cursor() ?? getCursorPosition(editor)
|
||||
prompt.set([...prompt.current(), attachment], cursor)
|
||||
return true
|
||||
}
|
||||
|
||||
const addAttachment = (file: File) => add(file)
|
||||
|
||||
const addAttachments = async (files: File[], toast = true, target = capture()) => {
|
||||
const addAttachments = async (files: File[], toast = true) => {
|
||||
let found = false
|
||||
|
||||
for (const file of files) {
|
||||
const ok = await add(file, false, target)
|
||||
const ok = await add(file, false)
|
||||
if (ok) found = true
|
||||
}
|
||||
|
||||
if (!found && files.length > 0 && toast) input.warn?.()
|
||||
if (!found && files.length > 0 && toast) warn()
|
||||
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 target = input.capture()
|
||||
const current = target.current()
|
||||
const current = prompt.current()
|
||||
const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
||||
target.set(next, target.cursor())
|
||||
prompt.set(next, prompt.cursor())
|
||||
}
|
||||
|
||||
const handlePaste = async (event: ClipboardEvent) => {
|
||||
const clipboardData = event.clipboardData
|
||||
if (!clipboardData) return
|
||||
const target = capture()
|
||||
if (!target) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -123,7 +107,7 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
|
||||
})
|
||||
|
||||
if (files.length > 0) {
|
||||
await addAttachments(files, true, target)
|
||||
await addAttachments(files)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -131,7 +115,11 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
|
||||
|
||||
// Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images
|
||||
if (input.readClipboardImage && !plainText) {
|
||||
if (await addClipboardAttachment(input.readClipboardImage(), target)) return
|
||||
const file = await input.readClipboardImage()
|
||||
if (file) {
|
||||
await addAttachment(file)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!plainText) return
|
||||
@@ -139,9 +127,9 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
|
||||
const text = normalizePaste(plainText)
|
||||
|
||||
const put = () => {
|
||||
if (input.addPart?.({ type: "text", content: text, start: 0, end: 0 })) return true
|
||||
input.focusEditor?.()
|
||||
return input.addPart?.({ type: "text", content: text, start: 0, end: 0 }) ?? false
|
||||
if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true
|
||||
input.focusEditor()
|
||||
return input.addPart({ type: "text", content: text, start: 0, end: 0 })
|
||||
}
|
||||
|
||||
if (pasteMode(text) === "manual") {
|
||||
@@ -155,28 +143,6 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
|
||||
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) => {
|
||||
if (input.isDialogActive()) return
|
||||
|
||||
@@ -215,7 +181,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
const dropped = event.dataTransfer?.files
|
||||
if (!dropped) return
|
||||
|
||||
await attachments.addAttachments(Array.from(dropped))
|
||||
await addAttachments(Array.from(dropped))
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -224,5 +190,10 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
makeEventListener(document, "drop", handleGlobalDrop)
|
||||
})
|
||||
|
||||
return attachments
|
||||
return {
|
||||
addAttachment,
|
||||
addAttachments,
|
||||
removeAttachment,
|
||||
handlePaste,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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 }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ let variant: string | undefined
|
||||
|
||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
const prompt = {
|
||||
ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
|
||||
ready: () => Object.assign(() => true, { promise: Promise.resolve(true) }),
|
||||
current: () => promptValue,
|
||||
cursor: () => 0,
|
||||
dirty: () => true,
|
||||
@@ -41,7 +41,6 @@ const prompt = {
|
||||
replaceComments: () => undefined,
|
||||
items: () => [],
|
||||
},
|
||||
capture: () => prompt,
|
||||
}
|
||||
|
||||
const clientFor = (directory: string) => {
|
||||
@@ -186,10 +185,6 @@ beforeAll(async () => {
|
||||
|
||||
mock.module("@/context/server-sync", () => ({
|
||||
useServerSync: () => () => ({
|
||||
session: {
|
||||
remember: () => undefined,
|
||||
set: () => undefined,
|
||||
},
|
||||
child: (directory: string) => {
|
||||
syncedDirectories.push(directory)
|
||||
storedSessions[directory] ??= []
|
||||
|
||||
@@ -4,6 +4,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { batch, type Accessor } from "solid-js"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||
@@ -20,7 +21,6 @@ import { buildRequestParts } from "./build-request-parts"
|
||||
import { setCursorPosition } from "./editor-dom"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { ScopedKey } from "@/utils/server-scope"
|
||||
import { createPromptSubmissionState } from "./submission-state"
|
||||
|
||||
type PendingPrompt = {
|
||||
abort: AbortController
|
||||
@@ -56,14 +56,16 @@ const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttac
|
||||
export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
const text = draftText(input.draft.prompt)
|
||||
const images = draftImages(input.draft.prompt)
|
||||
const [, setStore] = input.serverSync.child(input.draft.sessionDirectory)
|
||||
|
||||
const setBusy = () => {
|
||||
if (!input.optimisticBusy) return
|
||||
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "busy" })
|
||||
setStore("session_status", input.draft.sessionID, { type: "busy" })
|
||||
}
|
||||
|
||||
const setIdle = () => {
|
||||
if (!input.optimisticBusy) return
|
||||
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" })
|
||||
setStore("session_status", input.draft.sessionID, { type: "idle" })
|
||||
}
|
||||
|
||||
const wait = async () => {
|
||||
@@ -194,6 +196,15 @@ type PromptSubmitInput = {
|
||||
onSubmit?: () => void
|
||||
}
|
||||
|
||||
type CommentItem = {
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
@@ -223,7 +234,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return Promise.resolve()
|
||||
|
||||
serverSync().session.set("todo", sessionID, [])
|
||||
serverSync().todo.set(sessionID, [])
|
||||
const [, setStore] = serverSync().child(sdk().directory)
|
||||
setStore("todo", sessionID, [])
|
||||
|
||||
input.onAbort?.()
|
||||
|
||||
@@ -242,12 +255,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const restoreCommentItems = (
|
||||
target: ReturnType<ReturnType<typeof usePrompt>["capture"]>,
|
||||
items: (ContextItem & { key: string })[],
|
||||
) => {
|
||||
const restoreCommentItems = (items: CommentItem[]) => {
|
||||
for (const item of items) {
|
||||
target.context.add({
|
||||
prompt.context.add({
|
||||
type: "file",
|
||||
path: item.path,
|
||||
selection: item.selection,
|
||||
@@ -259,14 +269,19 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
}
|
||||
|
||||
const clearContext = (target: ReturnType<ReturnType<typeof usePrompt>["capture"]>) => {
|
||||
for (const item of target.context.items()) {
|
||||
target.context.remove(item.key)
|
||||
const removeCommentItems = (items: { key: string }[]) => {
|
||||
for (const item of items) {
|
||||
prompt.context.remove(item.key)
|
||||
}
|
||||
}
|
||||
|
||||
const clearContext = () => {
|
||||
for (const item of prompt.context.items()) {
|
||||
prompt.context.remove(item.key)
|
||||
}
|
||||
}
|
||||
|
||||
const seed = (dir: string, info: Session) => {
|
||||
serverSync().session.remember(info)
|
||||
const [, setStore] = serverSync().child(dir)
|
||||
setStore("session", (list: Session[]) => {
|
||||
const result = Binary.search(list, info.id, (item) => item.id)
|
||||
@@ -283,14 +298,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
|
||||
const target = prompt.capture()
|
||||
const submission = createPromptSubmissionState({
|
||||
target,
|
||||
prompt: target.current(),
|
||||
context: target.context.items().slice(),
|
||||
})
|
||||
const currentPrompt = submission.prompt
|
||||
const context = submission.context
|
||||
const currentPrompt = prompt.current()
|
||||
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||
const images = input.imageAttachments().slice()
|
||||
const mode = input.mode()
|
||||
@@ -380,9 +388,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
local.session.promote(sessionDirectory, session.id)
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
const draftID = search.draftId
|
||||
if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id })
|
||||
if (draftID)
|
||||
tabs.promoteDraft(draftID, {
|
||||
server: server.key,
|
||||
dirBase64: base64Encode(sessionDirectory),
|
||||
sessionId: session.id,
|
||||
})
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
||||
}
|
||||
}
|
||||
if (!session) {
|
||||
@@ -398,6 +410,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
providerID: currentModel.provider.id,
|
||||
}
|
||||
const agent = currentAgent.name
|
||||
const context = prompt.context.items().slice()
|
||||
const draft: FollowupDraft = {
|
||||
sessionID: session.id,
|
||||
sessionDirectory,
|
||||
@@ -409,16 +422,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
const clearInput = () => {
|
||||
submission.clear()
|
||||
prompt.reset()
|
||||
input.setMode("normal")
|
||||
input.setPopover(null)
|
||||
}
|
||||
|
||||
const restoreInput = () => {
|
||||
const restored = submission.restore()
|
||||
if (!restored) return false
|
||||
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
|
||||
if (!submission.current(prompt.capture())) return true
|
||||
prompt.set(currentPrompt, input.promptLength(currentPrompt))
|
||||
input.setMode(mode)
|
||||
input.setPopover(null)
|
||||
requestAnimationFrame(() => {
|
||||
@@ -428,12 +438,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
setCursorPosition(editor, input.promptLength(currentPrompt))
|
||||
input.queueScroll()
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
||||
input.onQueue?.(draft)
|
||||
clearContext(submission.target())
|
||||
clearContext()
|
||||
clearInput()
|
||||
return
|
||||
}
|
||||
@@ -503,7 +512,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
})
|
||||
}
|
||||
|
||||
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||
removeCommentItems(commentItems)
|
||||
clearInput()
|
||||
|
||||
const waitForWorktree = async () => {
|
||||
@@ -520,7 +529,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
sync().set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
removeOptimisticMessage()
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
restoreCommentItems(commentItems)
|
||||
restoreInput()
|
||||
}
|
||||
|
||||
pending.set(pendingKey(session.id), { abort: controller, cleanup })
|
||||
@@ -582,7 +592,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
description: errorMessage(err),
|
||||
})
|
||||
removeOptimisticMessage()
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
restoreCommentItems(commentItems)
|
||||
restoreInput()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { createComputed, on, type Accessor } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import type { PromptHistoryEntry } from "./history"
|
||||
|
||||
export type PromptInputTransientState = {
|
||||
popover: "at" | "slash" | null
|
||||
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
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
@@ -34,8 +33,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
const file = useFile()
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const sdk = useSDK()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const providers = useProviders()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
|
||||
const variant = createMemo(() => props.variant ?? "button")
|
||||
|
||||
@@ -7,13 +7,12 @@ import { same } from "@/utils/same"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { File } from "@opencode-ai/session-ui/file"
|
||||
import { Markdown } from "@opencode-ai/session-ui/markdown"
|
||||
import { File } from "@opencode-ai/ui/file"
|
||||
import { Markdown } from "@opencode-ai/ui/markdown"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||
@@ -94,8 +93,7 @@ const emptyUserMessages: UserMessage[] = []
|
||||
export function SessionContextTab() {
|
||||
const sync = useSync()
|
||||
const language = useLanguage()
|
||||
const sdk = useSDK()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const providers = useProviders()
|
||||
const { params, view } = useSessionLayout()
|
||||
|
||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -159,7 +158,6 @@ export function SessionHeader() {
|
||||
const isV2 = settings.general.newLayoutDesigns
|
||||
const search = settings.visibility.search
|
||||
const status = settings.visibility.status
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
|
||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||
finder: true,
|
||||
@@ -238,7 +236,6 @@ export function SessionHeader() {
|
||||
statusLabel: language.t("status.popover.trigger"),
|
||||
reviewLabel: language.t("command.review.toggle"),
|
||||
reviewKeybind: command.keybind("review.toggle"),
|
||||
reviewVisible: isDesktop(),
|
||||
reviewOpened: view().reviewPanel.opened(),
|
||||
onReviewToggle: () => view().reviewPanel.toggle(),
|
||||
}))
|
||||
@@ -521,7 +518,6 @@ type SessionHeaderV2ActionsState = {
|
||||
statusLabel: string
|
||||
reviewLabel: string
|
||||
reviewKeybind: string
|
||||
reviewVisible: boolean
|
||||
reviewOpened: boolean
|
||||
onReviewToggle: () => void
|
||||
}
|
||||
@@ -534,22 +530,20 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
||||
<StatusPopoverV2 />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={props.state.reviewVisible}>
|
||||
<TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}>
|
||||
<IconButtonV2
|
||||
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" />}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
<TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}>
|
||||
<IconButtonV2
|
||||
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" />}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -335,6 +335,18 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
</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
|
||||
title={language.t("settings.general.row.newLayoutDesigns.title")}
|
||||
description={language.t("settings.general.row.newLayoutDesigns.description")}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function SettingsServerScope(props: ParentProps) {
|
||||
|
||||
function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnection.Any }>) {
|
||||
const global = useGlobal()
|
||||
const serverCtx = () => global.ensureServerCtx(props.server)
|
||||
const serverCtx = () => global.createServerCtx(props.server)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={serverCtx().queryClient}>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
@@ -90,7 +89,6 @@ export const SettingsGeneralV2: Component = () => {
|
||||
const dialog = useDialog()
|
||||
const params = useParams()
|
||||
const settings = useSettings()
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
|
||||
const updater = useUpdaterAction()
|
||||
|
||||
@@ -318,6 +316,18 @@ export const SettingsGeneralV2: Component = () => {
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
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>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.newLayoutDesigns.title")}
|
||||
description={language.t("settings.general.row.newLayoutDesigns.description")}
|
||||
@@ -335,20 +345,6 @@ export const SettingsGeneralV2: Component = () => {
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<Show when={mobile()}>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
|
||||
description={language.t("settings.general.row.mobileTitlebarBottom.description")}
|
||||
>
|
||||
<div data-action="settings-mobile-titlebar-bottom">
|
||||
<Switch
|
||||
checked={settings.general.mobileTitlebarPosition() === "bottom"}
|
||||
onChange={(checked) => settings.general.setMobileTitlebarPosition(checked ? "bottom" : "top")}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
</Show>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
@@ -13,7 +14,7 @@ import { ServerConnection, serverName } from "@/context/server"
|
||||
import { useServerManagementController } from "../dialog-select-server"
|
||||
import { DialogServerV2 } from "./dialog-server-v2"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
|
||||
import { isWslServer, useFilteredWslServers, WslAddServerButton, WslServerSettings } from "@/wsl/settings"
|
||||
import "./settings-v2.css"
|
||||
|
||||
export const SettingsServersV2: Component = () => {
|
||||
@@ -54,7 +55,10 @@ export const SettingsServersV2: Component = () => {
|
||||
>
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
|
||||
<AddServerMenu onAddServer={openAdd} />
|
||||
<ButtonV2 variant="ghost-muted" icon="plus" onClick={openAdd}>
|
||||
{language.t("dialog.server.add.button")}
|
||||
</ButtonV2>
|
||||
<WslAddServerButton />
|
||||
</div>
|
||||
<Show when={showSearch()}>
|
||||
<div class="settings-v2-tab-search">
|
||||
|
||||
@@ -167,15 +167,6 @@
|
||||
background-color: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.settings-v2[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
|
||||
[data-slot="tabs-v2-list"] {
|
||||
width: 144px;
|
||||
min-width: 144px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-v2-nav-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { captureTabDragLayout, insertIndexFromVirtualLayout } from "./titlebar-tab-drag"
|
||||
import {
|
||||
canOpenTabRename,
|
||||
captureTabPointerDown,
|
||||
canStartTabDrag,
|
||||
createTabDragPreview,
|
||||
forwardTabRef,
|
||||
isPrimaryPointerPressed,
|
||||
isTabCloseTarget,
|
||||
} from "./titlebar-tab-gesture"
|
||||
|
||||
describe("titlebar tab drag", () => {
|
||||
const layout = {
|
||||
listLeft: 100,
|
||||
dividerWidth: 13,
|
||||
tabWidthById: new Map([
|
||||
["a", 40],
|
||||
["b", 40],
|
||||
["c", 40],
|
||||
["d", 40],
|
||||
]),
|
||||
}
|
||||
|
||||
test("moves across multiple tabs from one pointer update", () => {
|
||||
expect(insertIndexFromVirtualLayout(260, ["a", "b", "c", "d"], "a", 0, layout)).toBe(3)
|
||||
expect(insertIndexFromVirtualLayout(90, ["a", "b", "c", "d"], "d", 3, layout)).toBe(0)
|
||||
})
|
||||
|
||||
test("keeps the current index inside the left hysteresis deadband", () => {
|
||||
expect(insertIndexFromVirtualLayout(146, ["a", "b", "c", "d"], "b", 1, layout)).toBe(1)
|
||||
})
|
||||
|
||||
test("includes slot margins in captured divider width", () => {
|
||||
const list = document.createElement("div")
|
||||
const first = document.createElement("div")
|
||||
const second = document.createElement("div")
|
||||
const firstTab = document.createElement("div")
|
||||
const secondTab = document.createElement("div")
|
||||
first.dataset.titlebarTabSlot = ""
|
||||
first.dataset.tabKey = "a"
|
||||
second.dataset.titlebarTabSlot = ""
|
||||
second.dataset.tabKey = "b"
|
||||
second.style.marginLeft = "6px"
|
||||
firstTab.dataset.titlebarTab = ""
|
||||
secondTab.dataset.titlebarTab = ""
|
||||
first.append(firstTab)
|
||||
second.append(secondTab)
|
||||
list.append(first, second)
|
||||
document.body.append(list)
|
||||
firstTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
|
||||
secondTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
|
||||
second.getBoundingClientRect = () => ({ width: 47 }) as DOMRect
|
||||
list.getBoundingClientRect = () => ({ left: 100 }) as DOMRect
|
||||
|
||||
expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13)
|
||||
list.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe("titlebar tab gestures", () => {
|
||||
test("excludes close controls from tab gestures", () => {
|
||||
const close = document.createElement("div")
|
||||
const button = document.createElement("button")
|
||||
const link = document.createElement("a")
|
||||
close.dataset.slot = "tab-close"
|
||||
close.append(button)
|
||||
expect(isTabCloseTarget(close)).toBe(true)
|
||||
expect(isTabCloseTarget(button)).toBe(true)
|
||||
expect(isTabCloseTarget(link)).toBe(false)
|
||||
})
|
||||
|
||||
test("forwards component refs", () => {
|
||||
const element = document.createElement("div")
|
||||
let received: HTMLDivElement | undefined
|
||||
forwardTabRef((value) => (received = value), element)
|
||||
expect(received).toBe(element)
|
||||
})
|
||||
|
||||
test("does not reopen rename while a save is pending", () => {
|
||||
expect(canOpenTabRename(false, false, false)).toBe(true)
|
||||
expect(canOpenTabRename(false, false, true)).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps the rendered tab content in the drag preview", () => {
|
||||
const tab = document.createElement("div")
|
||||
tab.innerHTML = '<span data-slot="project-avatar-slot"></span><span data-slot="tab-title">Session</span>'
|
||||
const preview = createTabDragPreview(tab)
|
||||
expect(preview.querySelector('[data-slot="project-avatar-slot"]')).not.toBeNull()
|
||||
expect(preview.querySelector('[data-slot="tab-title"]')?.textContent).toBe("Session")
|
||||
})
|
||||
|
||||
test("captures the grab offset before navigation scrolls the tab", () => {
|
||||
const tab = document.createElement("div")
|
||||
tab.getBoundingClientRect = () => ({ left: 80, top: 10, width: 120 }) as DOMRect
|
||||
|
||||
expect(captureTabPointerDown(tab, 100, 20)).toEqual({
|
||||
startX: 100,
|
||||
startY: 20,
|
||||
grabOffsetX: 20,
|
||||
grabOffsetY: 10,
|
||||
width: 120,
|
||||
element: tab,
|
||||
})
|
||||
})
|
||||
|
||||
test("detects when the primary pointer button was released outside the window", () => {
|
||||
expect(isPrimaryPointerPressed(1)).toBe(true)
|
||||
expect(isPrimaryPointerPressed(3)).toBe(true)
|
||||
expect(isPrimaryPointerPressed(0)).toBe(false)
|
||||
expect(isPrimaryPointerPressed(2)).toBe(false)
|
||||
})
|
||||
|
||||
test("preserves native panning for touch pointers", () => {
|
||||
expect(canStartTabDrag("mouse")).toBe(true)
|
||||
expect(canStartTabDrag("pen")).toBe(true)
|
||||
expect(canStartTabDrag("touch")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,135 +0,0 @@
|
||||
export type TabDragLayout = {
|
||||
tabWidthById: Map<string, number>
|
||||
dividerWidth: number
|
||||
listLeft: number
|
||||
}
|
||||
|
||||
export const ACTIVATION_DISTANCE = 4
|
||||
export const HYSTERESIS_DEADBAND = 8
|
||||
export const AUTOSCROLL_EDGE = 24
|
||||
export const AUTOSCROLL_MAX_SPEED = 8
|
||||
export const FLOATER_OVERSHOOT_MAX = 8
|
||||
|
||||
export function pointerDistance(x1: number, y1: number, x2: number, y2: number) {
|
||||
const dx = x2 - x1
|
||||
const dy = y2 - y1
|
||||
return Math.sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
|
||||
export function captureTabDragLayout(list: HTMLElement, order: string[]) {
|
||||
const tabWidthById = new Map<string, number>()
|
||||
const slots = list.querySelectorAll<HTMLElement>("[data-titlebar-tab-slot]")
|
||||
for (const slot of slots) {
|
||||
const id = slot.dataset.tabKey
|
||||
if (!id) continue
|
||||
const tab = slot.querySelector<HTMLElement>("[data-titlebar-tab]")
|
||||
if (!tab) continue
|
||||
tabWidthById.set(id, tab.getBoundingClientRect().width)
|
||||
}
|
||||
|
||||
let dividerWidth = 0
|
||||
if (order.length >= 2) {
|
||||
const secondId = order[1]
|
||||
for (const slot of slots) {
|
||||
if (slot.dataset.tabKey !== secondId) continue
|
||||
const tab = slot.querySelector<HTMLElement>("[data-titlebar-tab]")
|
||||
if (!tab) break
|
||||
const style = getComputedStyle(slot)
|
||||
dividerWidth =
|
||||
slot.getBoundingClientRect().width -
|
||||
tab.getBoundingClientRect().width +
|
||||
(Number.parseFloat(style.marginLeft) || 0) +
|
||||
(Number.parseFloat(style.marginRight) || 0)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tabWidthById,
|
||||
dividerWidth,
|
||||
listLeft: list.getBoundingClientRect().left,
|
||||
}
|
||||
}
|
||||
|
||||
export function syncLayoutScroll(list: HTMLElement, layout: TabDragLayout) {
|
||||
layout.listLeft = list.getBoundingClientRect().left
|
||||
}
|
||||
|
||||
function slotWidthAt(order: readonly string[], index: number, layout: TabDragLayout) {
|
||||
const id = order[index]
|
||||
if (!id) return 0
|
||||
const tabWidth = layout.tabWidthById.get(id) ?? 0
|
||||
return index === 0 ? tabWidth : layout.dividerWidth + tabWidth
|
||||
}
|
||||
|
||||
function slotLeft(order: readonly string[], index: number, layout: TabDragLayout) {
|
||||
let left = layout.listLeft
|
||||
for (let i = 0; i < index; i++) {
|
||||
left += slotWidthAt(order, i, layout)
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
export function insertIndexFromVirtualLayout(
|
||||
pointerX: number,
|
||||
order: readonly string[],
|
||||
draggedId: string,
|
||||
currentIndex: number,
|
||||
layout: TabDragLayout,
|
||||
deadband = HYSTERESIS_DEADBAND,
|
||||
) {
|
||||
if (order.length === 0) return 0
|
||||
|
||||
const others = order.filter((id) => id !== draggedId)
|
||||
let target = currentIndex
|
||||
|
||||
while (target > 0 && pointerX < slotLeft(others, target, layout) - deadband) target--
|
||||
while (target < order.length - 1 && pointerX >= slotLeft(others, target + 1, layout)) target++
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
export function movePlaceholder(order: readonly string[], draggedId: string, toIndex: number) {
|
||||
const fromIndex = order.indexOf(draggedId)
|
||||
if (fromIndex === -1 || fromIndex === toIndex) return [...order]
|
||||
const next = [...order]
|
||||
next.splice(toIndex, 0, ...next.splice(fromIndex, 1))
|
||||
return next
|
||||
}
|
||||
|
||||
export function draftOrderChanged(initial: readonly string[], final: readonly string[]) {
|
||||
if (initial.length === 0 || final.length === 0 || initial.length !== final.length) return false
|
||||
return final.some((key, index) => key !== initial[index])
|
||||
}
|
||||
|
||||
function easeOvershoot(overshoot: number) {
|
||||
return (FLOATER_OVERSHOOT_MAX * overshoot) / (overshoot + FLOATER_OVERSHOOT_MAX)
|
||||
}
|
||||
|
||||
export function clampFloaterLeft(left: number, width: number, stripLeft: number, stripRight: number) {
|
||||
const stripWidth = stripRight - stripLeft
|
||||
if (width >= stripWidth) return stripLeft
|
||||
|
||||
const maxLeft = stripRight - width
|
||||
if (left > maxLeft) return maxLeft + easeOvershoot(left - maxLeft)
|
||||
if (left < stripLeft) return stripLeft - easeOvershoot(stripLeft - left)
|
||||
|
||||
return left
|
||||
}
|
||||
|
||||
export function autoscrollSpeed(pointerX: number, containerLeft: number, containerRight: number) {
|
||||
const leftEdge = containerLeft + AUTOSCROLL_EDGE
|
||||
const rightEdge = containerRight - AUTOSCROLL_EDGE
|
||||
|
||||
if (pointerX < leftEdge) {
|
||||
const depth = (leftEdge - pointerX) / AUTOSCROLL_EDGE
|
||||
return -Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
|
||||
}
|
||||
|
||||
if (pointerX > rightEdge) {
|
||||
const depth = (pointerX - rightEdge) / AUTOSCROLL_EDGE
|
||||
return Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { Ref } from "solid-js"
|
||||
|
||||
export function isTabCloseTarget(target: EventTarget | null) {
|
||||
return target instanceof Element && !!target.closest('[data-slot="tab-close"]')
|
||||
}
|
||||
|
||||
export function canStartTabDrag(pointerType: string) {
|
||||
return pointerType !== "touch"
|
||||
}
|
||||
|
||||
export function isPrimaryPointerPressed(buttons: number) {
|
||||
return (buttons & 1) !== 0
|
||||
}
|
||||
|
||||
export function captureTabPointerDown(element: HTMLDivElement, clientX: number, clientY: number) {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return {
|
||||
startX: clientX,
|
||||
startY: clientY,
|
||||
grabOffsetX: clientX - rect.left,
|
||||
grabOffsetY: clientY - rect.top,
|
||||
width: rect.width,
|
||||
element,
|
||||
}
|
||||
}
|
||||
|
||||
export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement) {
|
||||
if (typeof ref === "function") ref(element)
|
||||
}
|
||||
|
||||
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) {
|
||||
return !dragging && !editing && !committing
|
||||
}
|
||||
|
||||
export function createTabDragPreview(element: HTMLDivElement) {
|
||||
return element.cloneNode(true) as HTMLDivElement
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
[data-titlebar-tab] [data-slot="tab-close"] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-titlebar-tab] [data-slot="tab-close"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 28px;
|
||||
width: 16px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to right, transparent, var(--tab-bg));
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-close"]::before {
|
||||
display: block;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-title-overflow="true"]:hover:not([data-editing="true"]) [data-slot="tab-close"]::before,
|
||||
[data-titlebar-tab][data-title-overflow="true"][data-active="true"]:not([data-editing="true"])
|
||||
[data-slot="tab-close"]::before {
|
||||
right: 100%;
|
||||
}
|
||||
|
||||
[data-titlebar-tab] [data-slot="tab-link"] {
|
||||
padding-right: 22px;
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
|
||||
[data-slot="tab-link"] {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-editing="true"] [data-slot="tab-link"] {
|
||||
padding-right: 22px;
|
||||
}
|
||||
|
||||
[data-titlebar-tab] [data-slot="tab-title"] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-editing="true"] [data-slot="tab-title"] {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex: 1 1 auto;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-editing="true"] [data-slot="tab-title"]::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:hover [data-slot="tab-close"],
|
||||
[data-titlebar-tab][data-active="true"] [data-slot="tab-close"],
|
||||
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
|
||||
background: var(--tab-bg);
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { projectForSession } from "@/pages/layout/helpers"
|
||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
||||
import "./titlebar-tab-nav.css"
|
||||
|
||||
export function TabNavItem(props: {
|
||||
ref?: Ref<HTMLDivElement>
|
||||
href: string
|
||||
server: ServerConnection.Key
|
||||
session: () => Session | undefined
|
||||
onTitleChange?: (title: string) => void
|
||||
onTitleChangeFailed?: (title: string) => void
|
||||
onClose: () => void
|
||||
onNavigate: () => void
|
||||
active?: boolean
|
||||
activeServer: boolean
|
||||
forceTruncate?: boolean
|
||||
suppressNavigation?: () => boolean
|
||||
dragging?: boolean
|
||||
pressed?: boolean
|
||||
hidden?: boolean
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [editing, setEditing] = createSignal(false)
|
||||
const [titleOverflowing, setTitleOverflowing] = createSignal(false)
|
||||
let tabRoot!: HTMLDivElement
|
||||
let titleEl!: HTMLSpanElement
|
||||
let committing = false
|
||||
let measureFrame: number | undefined
|
||||
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onClose()
|
||||
}
|
||||
const global = useGlobal()
|
||||
const serverCtx = createMemo(() => {
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
|
||||
if (conn) return global.ensureServerCtx(conn)
|
||||
})
|
||||
const project = createMemo(() => {
|
||||
const session = props.session()
|
||||
if (!session) return
|
||||
return projectForSession(session, serverCtx()?.projects.list() ?? [])
|
||||
})
|
||||
|
||||
const measureTitleOverflow = () => {
|
||||
if (!titleEl || editing()) {
|
||||
setTitleOverflowing(false)
|
||||
return
|
||||
}
|
||||
setTitleOverflowing(titleEl.scrollWidth > titleEl.clientWidth)
|
||||
}
|
||||
|
||||
const scheduleTitleOverflow = () => {
|
||||
if (measureFrame !== undefined) return
|
||||
measureFrame = requestAnimationFrame(() => {
|
||||
measureFrame = undefined
|
||||
measureTitleOverflow()
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
props.session()?.title
|
||||
props.forceTruncate
|
||||
editing()
|
||||
scheduleTitleOverflow()
|
||||
})
|
||||
|
||||
createResizeObserver(() => tabRoot, scheduleTitleOverflow)
|
||||
onCleanup(() => {
|
||||
if (measureFrame !== undefined) cancelAnimationFrame(measureFrame)
|
||||
})
|
||||
|
||||
const selectTitle = () => {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(titleEl)
|
||||
const selection = window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
const rename = async (title: string) => {
|
||||
const ctx = serverCtx()
|
||||
const session = props.session()
|
||||
if (!ctx || !session) return
|
||||
const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true })
|
||||
await client.session.update({ sessionID: session.id, title })
|
||||
}
|
||||
|
||||
const closeRename = async (save: boolean) => {
|
||||
if (committing || !editing()) return
|
||||
committing = true
|
||||
|
||||
const original = props.session()?.title ?? ""
|
||||
const next = (titleEl.textContent ?? "").trim()
|
||||
|
||||
titleEl.scrollLeft = 0
|
||||
if (save && next && next !== original) props.onTitleChange?.(next)
|
||||
setEditing(false)
|
||||
|
||||
if (!save || !next || next === original) {
|
||||
committing = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await rename(next)
|
||||
} catch (err) {
|
||||
props.onTitleChangeFailed?.(original)
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
committing = false
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (editing()) return
|
||||
if (!titleEl) return
|
||||
const title = props.session()?.title
|
||||
if (title === undefined) return
|
||||
titleEl.textContent = title
|
||||
})
|
||||
|
||||
const openRename = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canOpenTabRename(props.dragging, editing(), committing)) return
|
||||
const session = props.session()
|
||||
if (!session) return
|
||||
titleEl.textContent = session.title
|
||||
setEditing(true)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
titleEl.focus()
|
||||
selectTitle()
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!editing()) return
|
||||
|
||||
const cleanup = makeEventListener(
|
||||
document,
|
||||
"pointerdown",
|
||||
(event) => {
|
||||
const target = event.target
|
||||
if (!(target instanceof Node)) return
|
||||
if (tabRoot.contains(target)) return
|
||||
void closeRename(true)
|
||||
},
|
||||
{ capture: true },
|
||||
)
|
||||
|
||||
onCleanup(cleanup)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(el) => {
|
||||
tabRoot = el
|
||||
forwardTabRef(props.ref, el)
|
||||
}}
|
||||
data-titlebar-tab
|
||||
data-title-overflow={titleOverflowing()}
|
||||
data-editing={editing()}
|
||||
class="group relative flex h-7 min-w-24 max-w-60 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
classList={{ invisible: props.hidden }}
|
||||
data-active={props.active}
|
||||
data-dragging={props.dragging}
|
||||
data-pressed={props.pressed}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<Show when={props.session()}>
|
||||
{(session) => {
|
||||
return (
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
if (editing()) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
<span data-slot="project-avatar-slot">
|
||||
<SessionTabAvatar
|
||||
project={project()}
|
||||
directory={session().directory}
|
||||
sessionId={session().id}
|
||||
activeServer={props.activeServer}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
ref={(el) => {
|
||||
titleEl = el
|
||||
titleEl.textContent = session().title
|
||||
}}
|
||||
data-slot="tab-title"
|
||||
class="min-w-0 flex-1 outline-none leading-4"
|
||||
classList={{
|
||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||
"select-text": editing(),
|
||||
}}
|
||||
contenteditable={editing() ? true : undefined}
|
||||
onDblClick={openRename}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void closeRename(true)
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
titleEl.textContent = session().title
|
||||
void closeRename(false)
|
||||
}}
|
||||
onBlur={() => void closeRename(true)}
|
||||
onPointerDown={(event) => {
|
||||
if (!editing()) return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (!editing()) return
|
||||
event.preventDefault()
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<div data-slot="tab-close">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="relative z-10 opacity-0 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DraftTabItem(props: {
|
||||
ref?: Ref<HTMLDivElement>
|
||||
href: string
|
||||
title: string
|
||||
active?: boolean
|
||||
onNavigate: () => void
|
||||
onClose: () => void
|
||||
suppressNavigation?: () => boolean
|
||||
dragging?: boolean
|
||||
pressed?: boolean
|
||||
hidden?: boolean
|
||||
}) {
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onClose()
|
||||
}
|
||||
return (
|
||||
<div
|
||||
ref={(el) => forwardTabRef(props.ref, el)}
|
||||
data-titlebar-tab
|
||||
data-active={props.active}
|
||||
data-dragging={props.dragging}
|
||||
data-pressed={props.pressed}
|
||||
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
|
||||
classList={{ invisible: props.hidden }}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-[var(--v2-text-text-base)]"
|
||||
>
|
||||
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
|
||||
<IconV2 name="edit" />
|
||||
</span>
|
||||
<span class="truncate leading-5">{props.title}</span>
|
||||
</a>
|
||||
<div data-slot="tab-close" class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
aria-label="Close tab"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,581 +0,0 @@
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createRoot,
|
||||
createSignal,
|
||||
For,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
|
||||
import { useGlobal, type ServerCtx } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { createTabPromptState } from "@/context/prompt"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import {
|
||||
captureTabPointerDown,
|
||||
canStartTabDrag,
|
||||
createTabDragPreview,
|
||||
isPrimaryPointerPressed,
|
||||
isTabCloseTarget,
|
||||
} from "./titlebar-tab-gesture"
|
||||
import {
|
||||
ACTIVATION_DISTANCE,
|
||||
autoscrollSpeed,
|
||||
captureTabDragLayout,
|
||||
clampFloaterLeft,
|
||||
draftOrderChanged,
|
||||
insertIndexFromVirtualLayout,
|
||||
movePlaceholder,
|
||||
pointerDistance,
|
||||
syncLayoutScroll,
|
||||
type TabDragLayout,
|
||||
} from "@/components/titlebar-tab-drag"
|
||||
|
||||
function SessionTabSlot(props: {
|
||||
tab: SessionTab
|
||||
id: string
|
||||
first: () => boolean
|
||||
active: () => boolean
|
||||
activeServerKey: ServerConnection.Key
|
||||
forceTruncate: boolean
|
||||
dragActive: boolean
|
||||
dragged: () => boolean
|
||||
pressed: () => boolean
|
||||
serverCtx: () => ServerCtx | undefined
|
||||
suppressNavigation: () => boolean
|
||||
onPointerDown: (event: PointerEvent) => void
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const tabs = useTabs()
|
||||
let ref!: HTMLDivElement
|
||||
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
|
||||
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
|
||||
const [loadedSession] = createResource(
|
||||
() => {
|
||||
const ctx = props.serverCtx()
|
||||
return ctx ? { id: props.tab.sessionId, ctx } : null
|
||||
},
|
||||
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
|
||||
)
|
||||
const session = createMemo(() => cachedSession() ?? loadedSession())
|
||||
let prefetched = false
|
||||
|
||||
createEffect(() => {
|
||||
const ctx = props.serverCtx()
|
||||
const value = session()
|
||||
if (!ctx || !value || prefetched) return
|
||||
prefetched = true
|
||||
createRoot((dispose) => {
|
||||
try {
|
||||
void ctx.sync
|
||||
.ensureDirSyncContext(value.directory)
|
||||
.session.sync(value.id)
|
||||
.catch(() => {})
|
||||
.finally(dispose)
|
||||
} catch {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const value = session()
|
||||
const current = sdk()
|
||||
if (!value || !current) return
|
||||
createTabPromptState(tabs, props.tab, current.scope, {
|
||||
dir: base64Encode(value.directory),
|
||||
id: value.id,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={props.id}
|
||||
class="flex shrink-0"
|
||||
classList={{
|
||||
hidden: !session(),
|
||||
"ml-1.5 border-l border-[var(--v2-background-bg-layer-02)] pl-1.5": !props.first(),
|
||||
"pointer-events-none": props.dragActive,
|
||||
}}
|
||||
onPointerDown={props.onPointerDown}
|
||||
>
|
||||
<TabNavItem
|
||||
ref={ref}
|
||||
href={tabHref(props.tab)}
|
||||
server={props.tab.server}
|
||||
session={session}
|
||||
onTitleChange={(title) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx()
|
||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||
}}
|
||||
onTitleChangeFailed={(title) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx()
|
||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||
}}
|
||||
onNavigate={() => props.onNavigate(ref)}
|
||||
onClose={props.onClose}
|
||||
active={props.active()}
|
||||
activeServer={props.tab.server === props.activeServerKey}
|
||||
forceTruncate={props.forceTruncate}
|
||||
suppressNavigation={props.suppressNavigation}
|
||||
pressed={props.pressed()}
|
||||
hidden={props.dragged()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TitlebarTabStrip(props: {
|
||||
tabs: Tab[]
|
||||
currentTab: () => Tab | undefined
|
||||
activeServerKey: ServerConnection.Key
|
||||
forceTruncate: boolean
|
||||
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
|
||||
onClose: (tab: Tab) => void
|
||||
onReorder: (keys: string[]) => void
|
||||
onOverflowChange: (overflowing: boolean) => void
|
||||
}) {
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const [drag, setDrag] = createStore({
|
||||
active: false,
|
||||
draggedId: undefined as string | undefined,
|
||||
placeholderIndex: 0,
|
||||
draftOrder: [] as string[],
|
||||
initialOrder: [] as string[],
|
||||
draggedWidth: 0,
|
||||
pointerX: 0,
|
||||
grabOffsetX: 0,
|
||||
floaterTop: 0,
|
||||
})
|
||||
|
||||
const [gesture, setGesture] = createStore({
|
||||
pending: undefined as
|
||||
| {
|
||||
id: string
|
||||
startX: number
|
||||
startY: number
|
||||
grabOffsetX: number
|
||||
grabOffsetY: number
|
||||
pointerId: number
|
||||
width: number
|
||||
element: HTMLDivElement
|
||||
}
|
||||
| undefined,
|
||||
})
|
||||
|
||||
const [suppressNavigation, setSuppressNavigation] = createSignal(false)
|
||||
const [pressedId, setPressedId] = createSignal<string | undefined>()
|
||||
const [stripScrollLeft, setStripScrollLeft] = createSignal(0)
|
||||
let scrollRef!: HTMLDivElement
|
||||
let listRef!: HTMLDivElement
|
||||
let dragLayout: TabDragLayout | undefined
|
||||
let dragPointerId: number | undefined
|
||||
let autoscrollFrame: number | undefined
|
||||
let resizeFrame: number | undefined
|
||||
let dragPreview: HTMLDivElement | undefined
|
||||
|
||||
const tabIds = () => props.tabs.map(tabKey)
|
||||
|
||||
const displayTabs = createMemo(() => {
|
||||
if (!drag.active || drag.draftOrder.length === 0) return props.tabs
|
||||
const byKey = new Map(props.tabs.map((tab) => [tabKey(tab), tab]))
|
||||
return drag.draftOrder.map((key) => byKey.get(key)).filter((tab): tab is Tab => !!tab)
|
||||
})
|
||||
|
||||
function refreshOverflow() {
|
||||
if (!scrollRef) return
|
||||
props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth)
|
||||
}
|
||||
|
||||
createResizeObserver(
|
||||
() => [scrollRef, listRef],
|
||||
() => {
|
||||
if (resizeFrame !== undefined) return
|
||||
resizeFrame = requestAnimationFrame(() => {
|
||||
resizeFrame = undefined
|
||||
refreshOverflow()
|
||||
if (!drag.active || !listRef) return
|
||||
dragLayout = captureTabDragLayout(listRef, drag.draftOrder)
|
||||
updateInsertIndex()
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
function syncScroll() {
|
||||
if (!scrollRef || !listRef || !dragLayout) return
|
||||
syncLayoutScroll(listRef, dragLayout)
|
||||
setStripScrollLeft(scrollRef.scrollLeft)
|
||||
updateInsertIndex()
|
||||
}
|
||||
|
||||
function stopAutoscroll() {
|
||||
if (autoscrollFrame === undefined) return
|
||||
cancelAnimationFrame(autoscrollFrame)
|
||||
autoscrollFrame = undefined
|
||||
}
|
||||
|
||||
function tickAutoscroll() {
|
||||
if (!drag.active || !scrollRef) return
|
||||
|
||||
const strip = scrollRef.getBoundingClientRect()
|
||||
const speed = autoscrollSpeed(drag.pointerX, strip.left, strip.right)
|
||||
|
||||
if (speed !== 0) {
|
||||
scrollRef.scrollLeft += speed
|
||||
syncScroll()
|
||||
}
|
||||
|
||||
autoscrollFrame = requestAnimationFrame(tickAutoscroll)
|
||||
}
|
||||
|
||||
function startAutoscroll() {
|
||||
stopAutoscroll()
|
||||
autoscrollFrame = requestAnimationFrame(tickAutoscroll)
|
||||
}
|
||||
|
||||
function applyPlaceholderIndex(nextIndex: number) {
|
||||
const id = drag.draggedId
|
||||
if (!id) return
|
||||
const next = movePlaceholder(drag.draftOrder, id, nextIndex)
|
||||
setDrag({
|
||||
draftOrder: next,
|
||||
placeholderIndex: nextIndex,
|
||||
})
|
||||
}
|
||||
|
||||
function updateInsertIndex() {
|
||||
if (!drag.active || !dragLayout) return
|
||||
const draggedId = drag.draggedId
|
||||
if (!draggedId) return
|
||||
const nextIndex = insertIndexFromVirtualLayout(
|
||||
drag.pointerX,
|
||||
drag.draftOrder,
|
||||
draggedId,
|
||||
drag.placeholderIndex,
|
||||
dragLayout,
|
||||
)
|
||||
if (nextIndex === drag.placeholderIndex) return
|
||||
applyPlaceholderIndex(nextIndex)
|
||||
}
|
||||
|
||||
function startDrag(id: string) {
|
||||
const order = tabIds()
|
||||
const index = order.indexOf(id)
|
||||
const pending = gesture.pending
|
||||
if (index === -1 || !pending || !listRef || !scrollRef) return
|
||||
|
||||
dragLayout = captureTabDragLayout(listRef, order)
|
||||
dragPreview = createTabDragPreview(pending.element)
|
||||
dragPointerId = pending.pointerId
|
||||
setGesture("pending", undefined)
|
||||
|
||||
setDrag({
|
||||
active: true,
|
||||
draggedId: id,
|
||||
placeholderIndex: index,
|
||||
draftOrder: order,
|
||||
initialOrder: order,
|
||||
draggedWidth: pending.width,
|
||||
pointerX: pending.startX,
|
||||
grabOffsetX: pending.grabOffsetX,
|
||||
floaterTop: pending.startY - pending.grabOffsetY,
|
||||
})
|
||||
setPressedId(undefined)
|
||||
setStripScrollLeft(scrollRef.scrollLeft)
|
||||
startAutoscroll()
|
||||
}
|
||||
|
||||
function endDrag(commit: boolean) {
|
||||
const initial = drag.initialOrder
|
||||
const final = drag.draftOrder
|
||||
const moved = drag.active
|
||||
|
||||
if (commit && moved && draftOrderChanged(initial, final)) {
|
||||
props.onReorder(final)
|
||||
}
|
||||
|
||||
if (moved) setSuppressNavigation(true)
|
||||
|
||||
setDrag({
|
||||
active: false,
|
||||
draggedId: undefined,
|
||||
placeholderIndex: 0,
|
||||
draftOrder: [],
|
||||
initialOrder: [],
|
||||
draggedWidth: 0,
|
||||
pointerX: 0,
|
||||
grabOffsetX: 0,
|
||||
floaterTop: 0,
|
||||
})
|
||||
|
||||
dragLayout = undefined
|
||||
dragPreview = undefined
|
||||
dragPointerId = undefined
|
||||
setGesture("pending", undefined)
|
||||
setPressedId(undefined)
|
||||
stopAutoscroll()
|
||||
refreshOverflow()
|
||||
requestAnimationFrame(() => setSuppressNavigation(false))
|
||||
}
|
||||
|
||||
function onPointerDown(id: string, event: PointerEvent) {
|
||||
if (event.button !== 0 || drag.active) return
|
||||
if (!canStartTabDrag(event.pointerType)) return
|
||||
if (isTabCloseTarget(event.target)) return
|
||||
const tabEl = (event.currentTarget as HTMLElement).querySelector<HTMLDivElement>("[data-titlebar-tab]")
|
||||
if (!tabEl) return
|
||||
if (!tabEl.querySelector('[data-slot="tab-link"]')) return
|
||||
const tab = props.tabs.find((item) => tabKey(item) === id)
|
||||
if (!tab) return
|
||||
const pointer = captureTabPointerDown(tabEl, event.clientX, event.clientY)
|
||||
setSuppressNavigation(true)
|
||||
props.onNavigate(tab, tabEl)
|
||||
setPressedId(id)
|
||||
setGesture("pending", {
|
||||
id,
|
||||
pointerId: event.pointerId,
|
||||
...pointer,
|
||||
})
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
const pending = gesture.pending
|
||||
if (pending && event.pointerId !== pending.pointerId) return
|
||||
if (drag.active && dragPointerId !== undefined && event.pointerId !== dragPointerId) return
|
||||
if (!isPrimaryPointerPressed(event.buttons)) {
|
||||
if (drag.active) endDrag(true)
|
||||
if (pending) {
|
||||
setGesture("pending", undefined)
|
||||
setPressedId(undefined)
|
||||
requestAnimationFrame(() => setSuppressNavigation(false))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (pending && !drag.active) {
|
||||
if (pointerDistance(pending.startX, pending.startY, event.clientX, event.clientY) < ACTIVATION_DISTANCE) return
|
||||
startDrag(pending.id)
|
||||
}
|
||||
|
||||
if (!drag.active) return
|
||||
|
||||
setDrag("pointerX", event.clientX)
|
||||
syncScroll()
|
||||
}
|
||||
|
||||
function onPointerUp(event: PointerEvent) {
|
||||
if (drag.active) {
|
||||
if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
|
||||
setDrag("pointerX", event.clientX)
|
||||
syncScroll()
|
||||
endDrag(true)
|
||||
return
|
||||
}
|
||||
|
||||
const pending = gesture.pending
|
||||
if (pending && event.pointerId !== pending.pointerId) return
|
||||
|
||||
setGesture("pending", undefined)
|
||||
setPressedId(undefined)
|
||||
requestAnimationFrame(() => setSuppressNavigation(false))
|
||||
}
|
||||
|
||||
function onPointerCancel(event: PointerEvent) {
|
||||
if (drag.active) {
|
||||
if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
|
||||
endDrag(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!gesture.pending) return
|
||||
if (gesture.pending.pointerId !== event.pointerId) return
|
||||
setGesture("pending", undefined)
|
||||
setPressedId(undefined)
|
||||
requestAnimationFrame(() => setSuppressNavigation(false))
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const cleanups = [
|
||||
makeEventListener(window, "pointermove", onPointerMove),
|
||||
makeEventListener(window, "pointerup", onPointerUp),
|
||||
makeEventListener(window, "pointercancel", onPointerCancel),
|
||||
]
|
||||
refreshOverflow()
|
||||
onCleanup(() => cleanups.forEach((cleanup) => cleanup()))
|
||||
})
|
||||
|
||||
onCleanup(stopAutoscroll)
|
||||
onCleanup(() => {
|
||||
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.tabs.length
|
||||
tabIds()
|
||||
refreshOverflow()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!drag.active || !scrollRef) return
|
||||
onCleanup(makeEventListener(scrollRef, "scroll", syncScroll))
|
||||
})
|
||||
|
||||
const floaterStyle = () => {
|
||||
stripScrollLeft()
|
||||
const strip = scrollRef?.getBoundingClientRect()
|
||||
const left = strip
|
||||
? clampFloaterLeft(drag.pointerX - drag.grabOffsetX, drag.draggedWidth, strip.left, strip.right)
|
||||
: drag.pointerX - drag.grabOffsetX
|
||||
|
||||
return {
|
||||
position: "fixed" as const,
|
||||
top: `${drag.floaterTop}px`,
|
||||
left: `${left}px`,
|
||||
width: `${drag.draggedWidth}px`,
|
||||
"z-index": "10000",
|
||||
"pointer-events": "none" as const,
|
||||
}
|
||||
}
|
||||
|
||||
const draggedTab = createMemo(() => {
|
||||
const id = drag.draggedId
|
||||
if (!id) return
|
||||
return props.tabs.find((tab) => tabKey(tab) === id)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div data-slot="titlebar-tabs" class="relative min-w-0">
|
||||
<div
|
||||
data-slot="titlebar-tabs-scroll"
|
||||
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
|
||||
ref={scrollRef}
|
||||
>
|
||||
<div class="flex min-w-0 flex-row items-center" ref={listRef}>
|
||||
<For each={displayTabs()}>
|
||||
{(tab, index) => {
|
||||
const id = tabKey(tab)
|
||||
const first = () => index() === 0
|
||||
let ref!: HTMLDivElement
|
||||
useTabShortcut(index, () => props.onNavigate(tab, ref))
|
||||
|
||||
const dragged = () => drag.active && drag.draggedId === id
|
||||
const serverCtx = createMemo(() => {
|
||||
if (tab.type !== "session") return
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
|
||||
if (conn) return global.ensureServerCtx(conn)
|
||||
})
|
||||
|
||||
if (tab.type === "session") {
|
||||
return (
|
||||
<SessionTabSlot
|
||||
tab={tab}
|
||||
id={id}
|
||||
first={first}
|
||||
active={() => props.currentTab() === tab}
|
||||
activeServerKey={props.activeServerKey}
|
||||
forceTruncate={props.forceTruncate}
|
||||
dragActive={drag.active}
|
||||
dragged={dragged}
|
||||
pressed={() => pressedId() === id}
|
||||
serverCtx={serverCtx}
|
||||
suppressNavigation={() => suppressNavigation()}
|
||||
onPointerDown={(event) => {
|
||||
if (dragged()) return
|
||||
onPointerDown(id, event)
|
||||
}}
|
||||
onNavigate={(element) => props.onNavigate(tab, element)}
|
||||
onClose={() => props.onClose(tab)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={id}
|
||||
class="flex shrink-0"
|
||||
classList={{
|
||||
"ml-1.5 border-l border-[var(--v2-background-bg-layer-02)] pl-1.5": !first(),
|
||||
"pointer-events-none": drag.active,
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
if (dragged()) return
|
||||
onPointerDown(id, event)
|
||||
}}
|
||||
>
|
||||
<DraftTabItem
|
||||
ref={ref}
|
||||
href={tabHref(tab)}
|
||||
title={language.t("command.session.new")}
|
||||
onNavigate={() => props.onNavigate(tab, ref)}
|
||||
onClose={() => props.onClose(tab)}
|
||||
suppressNavigation={() => suppressNavigation()}
|
||||
active={props.currentTab() === tab}
|
||||
pressed={pressedId() === id}
|
||||
hidden={dragged()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-slot="titlebar-tabs-fade-left"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
|
||||
/>
|
||||
<div
|
||||
data-slot="titlebar-tabs-fade-right"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
|
||||
/>
|
||||
</div>
|
||||
<Show when={drag.active && draggedTab() && dragPreview}>
|
||||
{(_) => (
|
||||
<Portal>
|
||||
<div data-titlebar-tab-preview style={floaterStyle()}>
|
||||
{dragPreview}
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function useTabShortcut(index: () => number, onSelect: () => void) {
|
||||
const command = useCommand()
|
||||
|
||||
command.register(() => {
|
||||
const number = index() + 1
|
||||
if (number > 9) return []
|
||||
return [
|
||||
{
|
||||
id: `tab.${number}`,
|
||||
category: "tab",
|
||||
title: "",
|
||||
keybind: `mod+${number}`,
|
||||
hidden: true,
|
||||
onSelect,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,15 @@
|
||||
import { createEffect, createMemo, createResource, createSignal, Match, Show, Switch, untrack } from "solid-js"
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
onMount,
|
||||
Show,
|
||||
Switch,
|
||||
untrack,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -11,20 +22,25 @@ 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 { LayoutRoute, useLayout } from "@/context/layout"
|
||||
import { getProjectAvatarVariant, LayoutRoute, useLayout, type LocalProject } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { WindowsAppMenu } from "./windows-app-menu"
|
||||
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
||||
import { TitlebarTabStrip } from "@/components/titlebar-tab-strip"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
|
||||
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { tabKey, useTabs } from "@/context/tabs"
|
||||
import { tabHref, useTabs, type Tab } from "@/context/tabs"
|
||||
import "./titlebar.css"
|
||||
|
||||
type TauriDesktopWindow = {
|
||||
@@ -71,8 +87,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const location = useLocation()
|
||||
const params = useParams()
|
||||
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
const bottom = createMemo(() => useV2Titlebar() && mobile() && settings.general.mobileTitlebarPosition() === "bottom")
|
||||
|
||||
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
||||
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
||||
@@ -221,11 +235,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
"shrink-0 relative flex flex-row": true,
|
||||
"h-9 bg-v2-background-bg-deep overflow-visible": useV2Titlebar(),
|
||||
"h-10 bg-background-base overflow-hidden": !useV2Titlebar(),
|
||||
"order-last": bottom(),
|
||||
}}
|
||||
style={{
|
||||
"min-height": minHeight(),
|
||||
"padding-left": mac() && !mobile() ? `${84 / zoom()}px` : 0,
|
||||
"padding-left": mac() ? `${84 / zoom()}px` : 0,
|
||||
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
||||
"max-width": electronWindows()
|
||||
? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
|
||||
@@ -239,27 +252,22 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
<Switch>
|
||||
<Match when={useV2Titlebar()}>
|
||||
{(_) => {
|
||||
const serverSync = useServerSync()
|
||||
const navigate = useNavigate()
|
||||
const layout = useLayout()
|
||||
const global = useGlobal()
|
||||
|
||||
const newSessionHref = () => {
|
||||
if (params.dir) return `/${params.dir}/session`
|
||||
|
||||
const project = layout.projects.list()[0]
|
||||
if (!project) return "/"
|
||||
|
||||
return `/${base64Encode(project.worktree)}/session`
|
||||
}
|
||||
|
||||
const tabs = useTabs()
|
||||
const tabsStore = tabs.store
|
||||
const tabsStoreActions = tabs
|
||||
const [session] = createResource(
|
||||
() => {
|
||||
const route = layout.route()
|
||||
if (route.type !== "session") return undefined
|
||||
const conn = global.servers
|
||||
.list()
|
||||
.find((item) => ServerConnection.key(item) === (route.server ?? server.key))
|
||||
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
|
||||
},
|
||||
({ route, sdk }) =>
|
||||
sdk.client.session
|
||||
.get({ sessionID: route.sessionId })
|
||||
.then((x) => x.data)
|
||||
.catch(() => {}),
|
||||
)
|
||||
|
||||
const matchRoute = (route: LayoutRoute) => {
|
||||
if (route.type === "home") return
|
||||
@@ -272,9 +280,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
|
||||
)
|
||||
if (main) return main
|
||||
const s = session()
|
||||
if (s?.parentID) {
|
||||
const parentID = s.parentID
|
||||
const sync = serverSync().createDirSyncContext(route.dir)
|
||||
const session = sync.session.get(route.sessionId)
|
||||
if (session?.parentID) {
|
||||
const parentID = session.parentID
|
||||
const parent = tabsStore.find(
|
||||
(item) => item.type === "session" && item.server === route.server && item.sessionId === parentID,
|
||||
)
|
||||
@@ -295,10 +304,15 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
}
|
||||
|
||||
if (route.type === "session") {
|
||||
const s = session()
|
||||
if (!s) return
|
||||
const sessionId = s.parentID ?? s.id
|
||||
const next = { server: route.server ?? server.key, sessionId }
|
||||
const sync = serverSync().createDirSyncContext(route.dir)
|
||||
const session = sync.session.get(route.sessionId)
|
||||
if (!session) return
|
||||
const sessionId = session.parentID ?? session.id
|
||||
const next = {
|
||||
server: route.server ?? server.key,
|
||||
dirBase64: route.dirBase64,
|
||||
sessionId,
|
||||
}
|
||||
tabsStoreActions.addSessionTab(next)
|
||||
}
|
||||
})
|
||||
@@ -309,28 +323,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
tabsStoreActions.removeSessions(detail)
|
||||
})
|
||||
|
||||
const openNewTab = () => {
|
||||
const route = layout.route()
|
||||
const activeSession = session()
|
||||
if (route.type === "session" && activeSession) {
|
||||
tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "")
|
||||
return
|
||||
}
|
||||
|
||||
const current = layout.projects.list()[0]
|
||||
if (current) {
|
||||
tabs.newDraft({ server: server.key, directory: current.worktree }, "")
|
||||
return
|
||||
}
|
||||
|
||||
const fallback = global.servers.list().flatMap((conn) => {
|
||||
const project = global.ensureServerCtx(conn).projects.list()[0]
|
||||
return project ? [{ server: ServerConnection.key(conn), project }] : []
|
||||
})[0]
|
||||
if (!fallback) return
|
||||
|
||||
tabs.newDraft({ server: fallback.server, directory: fallback.project.worktree }, "")
|
||||
}
|
||||
const openNewTab = () => navigate(newSessionHref())
|
||||
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
|
||||
|
||||
command.register("titlebar-home", () => [
|
||||
@@ -400,19 +393,38 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
if (next) tabs.select(next)
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => {
|
||||
const index = i
|
||||
const number = index + 1
|
||||
return {
|
||||
id: `tab.${number}`,
|
||||
category: "tab",
|
||||
title: "",
|
||||
keybind: `mod+${number}`,
|
||||
disabled: layout.projects.list().length <= index,
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
const tab = tabsStore[index]
|
||||
if (tab) tabs.select(tab)
|
||||
},
|
||||
}
|
||||
}),
|
||||
].filter((v) => v !== undefined)
|
||||
})
|
||||
|
||||
const [tabsAreOverflowing, setTabsAreOverflowing] = createSignal(false)
|
||||
let tabScrollRef!: HTMLDivElement
|
||||
|
||||
function refreshTabsAreOverflowing() {
|
||||
setTabsAreOverflowing(tabScrollRef.scrollWidth > tabScrollRef.clientWidth)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pr-3"
|
||||
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 pr-3 pt-2"
|
||||
classList={{
|
||||
"pt-2": !bottom(),
|
||||
"pb-2": bottom(),
|
||||
"md:pl-2": mac(),
|
||||
"md:pl-4": !mac(),
|
||||
"pl-2": mac(),
|
||||
"pl-4": !mac(),
|
||||
}}
|
||||
>
|
||||
<ChannelIndicator />
|
||||
@@ -442,22 +454,108 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
/>
|
||||
</TooltipV2>
|
||||
|
||||
<TitlebarTabStrip
|
||||
tabs={tabsStore}
|
||||
currentTab={currentTab}
|
||||
activeServerKey={server.key}
|
||||
forceTruncate={tabsAreOverflowing()}
|
||||
onOverflowChange={setTabsAreOverflowing}
|
||||
onNavigate={(tab, el) => {
|
||||
tabs.select(tab)
|
||||
el?.scrollIntoView({ behavior: "instant" })
|
||||
}}
|
||||
onClose={(tab) => {
|
||||
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
|
||||
if (index !== -1) tabsStoreActions.removeTab(index)
|
||||
}}
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
<div data-slot="titlebar-tabs" class="relative min-w-0">
|
||||
<div
|
||||
data-slot="titlebar-tabs-scroll"
|
||||
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
|
||||
ref={(el) => {
|
||||
tabScrollRef = el
|
||||
createResizeObserver(el, refreshTabsAreOverflowing)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 flex-row items-center gap-1.5"
|
||||
ref={(el) => createResizeObserver(el, refreshTabsAreOverflowing)}
|
||||
>
|
||||
<For each={tabsStore}>
|
||||
{(tab, i) => {
|
||||
let ref!: HTMLDivElement
|
||||
|
||||
const divider = () =>
|
||||
i() !== 0 && (
|
||||
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
|
||||
)
|
||||
|
||||
if (tab.type === "draft") {
|
||||
return (
|
||||
<>
|
||||
{divider()}
|
||||
<DraftTabItem
|
||||
ref={ref}
|
||||
href={tabHref(tab)}
|
||||
title={language.t("command.session.new")}
|
||||
active={currentTab() === tab}
|
||||
onNavigate={() => {
|
||||
tabs.select(tab)
|
||||
ref.scrollIntoView({ behavior: "instant" })
|
||||
}}
|
||||
onClose={() => tabsStoreActions.removeTab(i())}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{divider()}
|
||||
<TabNavItem
|
||||
ref={ref}
|
||||
href={tabHref(tab)}
|
||||
server={tab.server}
|
||||
directory={decode64(tab.dirBase64)!}
|
||||
sessionId={tab.sessionId}
|
||||
onNavigate={() => {
|
||||
tabs.select(tab)
|
||||
|
||||
ref.scrollIntoView({ behavior: "instant" })
|
||||
}}
|
||||
onClose={() => tabsStoreActions.removeTab(i())}
|
||||
active={currentTab() === tab}
|
||||
activeServer={tab.server === server.key}
|
||||
forceTruncate={tabsAreOverflowing()}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={creating() && params.dir}>
|
||||
{(_) => {
|
||||
let ref!: HTMLDivElement
|
||||
|
||||
onMount(() => {
|
||||
ref.scrollIntoView({ behavior: "instant" })
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
|
||||
<NewSessionTabItem
|
||||
ref={ref}
|
||||
href={`/${params.dir}/session`}
|
||||
title={language.t("command.session.new")}
|
||||
onClose={() => {
|
||||
const tab = tabsStore.at(-1)
|
||||
if (tab) tabs.select(tab)
|
||||
else navigate("/")
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-slot="titlebar-tabs-fade-left"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
|
||||
/>
|
||||
<div
|
||||
data-slot="titlebar-tabs-fade-right"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
|
||||
/>
|
||||
</div>
|
||||
<Show when={!(creating() && params.dir)}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
@@ -465,7 +563,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
size="large"
|
||||
class="shrink-0"
|
||||
icon={<IconV2 name="plus" />}
|
||||
onClick={openNewTab}
|
||||
as="a"
|
||||
href={newSessionHref()}
|
||||
aria-label={language.t("command.session.new")}
|
||||
/>
|
||||
</Show>
|
||||
@@ -690,6 +789,219 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
||||
)
|
||||
}
|
||||
|
||||
function TabNavItem(props: {
|
||||
ref?: HTMLDivElement
|
||||
href: string
|
||||
server: ServerConnection.Key
|
||||
directory: string
|
||||
sessionId?: string
|
||||
hideClose?: boolean
|
||||
onClose: () => void
|
||||
onNavigate: () => void
|
||||
active?: boolean
|
||||
activeServer: boolean
|
||||
forceTruncate?: boolean
|
||||
}) {
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onClose()
|
||||
}
|
||||
const global = useGlobal()
|
||||
const serverCtx = createMemo(() => {
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
|
||||
if (conn) return global.createServerCtx(conn)
|
||||
})
|
||||
const dirSyncCtx = createMemo(() => serverCtx()?.sync.createDirSyncContext(props.directory))
|
||||
|
||||
const [session] = createResource(
|
||||
() => {
|
||||
const ctx = dirSyncCtx()
|
||||
if (!ctx || !props.sessionId) return
|
||||
return [props.sessionId, ctx] as const
|
||||
},
|
||||
async ([sessionId, dirSyncCtx]) => {
|
||||
await dirSyncCtx.session.sync(sessionId).catch(() => {})
|
||||
return dirSyncCtx.session.get(sessionId)
|
||||
},
|
||||
{ initialValue: props.sessionId ? dirSyncCtx()?.session.get(props.sessionId) : undefined },
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={props.ref}
|
||||
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
data-active={props.active}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<Show when={session.latest}>
|
||||
{(session) => {
|
||||
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
|
||||
|
||||
return (
|
||||
<a
|
||||
href={props.href}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
|
||||
>
|
||||
<span data-slot="project-avatar-slot">
|
||||
<ProjectTabAvatar
|
||||
project={project()}
|
||||
directory={props.directory}
|
||||
sessionId={session().id}
|
||||
activeServer={props.activeServer}
|
||||
/>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">{session().title}</span>
|
||||
</a>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<div
|
||||
class="absolute not-group-hover:not-group-data-[active=true]:not-data-[truncate=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 data-[truncate=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2"
|
||||
data-truncate={props.forceTruncate}
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 rounded-r-[6px] bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
|
||||
style={{
|
||||
"--inactive-bg": "linear-gradient(to right, transparent 0%, var(--tab-bg) 80%)",
|
||||
"--active-bg": "linear-gradient(90deg, transparent 0%, var(--tab-bg) 25%)",
|
||||
}}
|
||||
/>
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100 z-10"
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectTabAvatar(props: {
|
||||
project?: LocalProject
|
||||
directory: string
|
||||
sessionId: string
|
||||
activeServer: boolean
|
||||
}) {
|
||||
const directory = () => props.directory
|
||||
const sessionId = () => props.sessionId
|
||||
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
|
||||
return (
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project ?? { worktree: props.directory })}
|
||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||
unread={state.unread()}
|
||||
loading={state.loading()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DraftTabItem(props: {
|
||||
ref?: HTMLDivElement
|
||||
href: string
|
||||
title: string
|
||||
active?: boolean
|
||||
onNavigate: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onClose()
|
||||
}
|
||||
return (
|
||||
<div
|
||||
ref={props.ref}
|
||||
data-active={props.active}
|
||||
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={props.href}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-[var(--v2-text-text-base)]"
|
||||
>
|
||||
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
|
||||
<IconV2 name="edit" />
|
||||
</span>
|
||||
<span class="truncate leading-5">{props.title}</span>
|
||||
</a>
|
||||
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
aria-label="Close tab"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NewSessionTabItem(props: { ref?: HTMLDivElement; href: string; title: string; onClose: () => void }) {
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onClose()
|
||||
}
|
||||
return (
|
||||
<div
|
||||
ref={props.ref}
|
||||
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={props.href}
|
||||
aria-current="page"
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-[var(--v2-text-text-base)]"
|
||||
>
|
||||
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
|
||||
<IconV2 name="edit" />
|
||||
</span>
|
||||
<span class="truncate leading-5">{props.title}</span>
|
||||
</a>
|
||||
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
aria-label="Close tab"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChannelIndicator() {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -2,14 +2,12 @@ import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
||||
import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { createScopedCache } from "@/utils/scoped-cache"
|
||||
import { uuid } from "@/utils/uuid"
|
||||
import type { SelectedLineRange } from "@/context/file"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
export type LineComment = {
|
||||
id: string
|
||||
@@ -204,7 +202,6 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
||||
gate: false,
|
||||
init: () => {
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
const cache = createScopedCache(
|
||||
(key) => {
|
||||
@@ -231,7 +228,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
||||
return cache.get(key).value
|
||||
}
|
||||
|
||||
const session = createMemo(() => load(base64Encode(sdk().directory), params.id))
|
||||
const session = createMemo(() => load(params.dir!, params.id))
|
||||
|
||||
return {
|
||||
ready: () => session().ready(),
|
||||
|
||||
@@ -1,23 +1,175 @@
|
||||
import { batch, createMemo } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { createMemo } from "solid-js"
|
||||
import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
||||
import type { createServerSdkContext } from "./server-sdk"
|
||||
import type { createServerSyncContextInner } from "./server-sync"
|
||||
import type { State } from "./global-sync/types"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
import {
|
||||
clearSessionPrefetch,
|
||||
getSessionPrefetch,
|
||||
getSessionPrefetchPromise,
|
||||
setSessionPrefetch,
|
||||
} from "./global-sync/session-prefetch"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
||||
import { diffs as list, message as clean } from "@/utils/diffs"
|
||||
import { type createServerSdkContext } from "./server-sdk"
|
||||
import { type createServerSyncContextInner } from "./server-sync"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
|
||||
function sortParts(parts: Part[]) {
|
||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
|
||||
const pending = map.get(key)
|
||||
if (pending) return pending
|
||||
const promise = task().finally(() => {
|
||||
map.delete(key)
|
||||
})
|
||||
map.set(key, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const sessionFields = new Set([
|
||||
"session_status",
|
||||
"session_working",
|
||||
"session_diff",
|
||||
"todo",
|
||||
"permission",
|
||||
"question",
|
||||
"message",
|
||||
"part",
|
||||
"part_text_accum_delta",
|
||||
])
|
||||
|
||||
const isNotFound = (error: unknown) =>
|
||||
error instanceof Error &&
|
||||
typeof error.cause === "object" &&
|
||||
error.cause !== null &&
|
||||
(error.cause as { status?: unknown }).status === 404
|
||||
|
||||
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
|
||||
const map = new Map(a.map((item) => [item.id, item] as const))
|
||||
for (const item of b) map.set(item.id, item)
|
||||
return [...map.values()].sort((x, y) => cmp(x.id, y.id))
|
||||
}
|
||||
|
||||
type OptimisticStore = {
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
}
|
||||
|
||||
type OptimisticAddInput = {
|
||||
sessionID: string
|
||||
message: Message
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
type OptimisticRemoveInput = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
}
|
||||
|
||||
type OptimisticItem = {
|
||||
message: Message
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
type MessagePage = {
|
||||
session: Message[]
|
||||
part: { id: string; part: Part[] }[]
|
||||
cursor?: string
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
|
||||
if (!parts) return want.length === 0
|
||||
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
|
||||
}
|
||||
|
||||
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
|
||||
if (!parts) return sortParts(want)
|
||||
const next = [...parts]
|
||||
let changed = false
|
||||
for (const part of want) {
|
||||
const result = Binary.search(next, part.id, (item) => item.id)
|
||||
if (result.found) continue
|
||||
next.splice(result.index, 0, part)
|
||||
changed = true
|
||||
}
|
||||
if (!changed) return parts
|
||||
return next
|
||||
}
|
||||
|
||||
export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
||||
if (items.length === 0) return { ...page, confirmed: [] as string[] }
|
||||
|
||||
const session = [...page.session]
|
||||
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
|
||||
const confirmed: string[] = []
|
||||
|
||||
for (const item of items) {
|
||||
const result = Binary.search(session, item.message.id, (message) => message.id)
|
||||
const found = result.found
|
||||
if (!found) session.splice(result.index, 0, item.message)
|
||||
|
||||
const current = part.get(item.message.id)
|
||||
if (found && hasParts(current, item.parts)) {
|
||||
confirmed.push(item.message.id)
|
||||
continue
|
||||
}
|
||||
|
||||
part.set(item.message.id, mergeParts(current, item.parts))
|
||||
}
|
||||
|
||||
return {
|
||||
cursor: page.cursor,
|
||||
complete: page.complete,
|
||||
session,
|
||||
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
|
||||
confirmed,
|
||||
}
|
||||
}
|
||||
|
||||
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
|
||||
const messages = draft.message[input.sessionID]
|
||||
if (messages) {
|
||||
const result = Binary.search(messages, input.message.id, (m) => m.id)
|
||||
messages.splice(result.index, 0, input.message)
|
||||
} else {
|
||||
draft.message[input.sessionID] = [input.message]
|
||||
}
|
||||
draft.part[input.message.id] = sortParts(input.parts)
|
||||
}
|
||||
|
||||
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
|
||||
const messages = draft.message[input.sessionID]
|
||||
if (messages) {
|
||||
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
||||
if (result.found) messages.splice(result.index, 1)
|
||||
}
|
||||
delete draft.part[input.messageID]
|
||||
}
|
||||
|
||||
function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: OptimisticAddInput) {
|
||||
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
|
||||
if (!messages) return [input.message]
|
||||
const result = Binary.search(messages, input.message.id, (m) => m.id)
|
||||
const next = [...messages]
|
||||
next.splice(result.index, 0, input.message)
|
||||
return next
|
||||
})
|
||||
setStore("part", input.message.id, sortParts(input.parts))
|
||||
}
|
||||
|
||||
function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: OptimisticRemoveInput) {
|
||||
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
|
||||
if (!messages) return messages
|
||||
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
||||
if (!result.found) return messages
|
||||
const next = [...messages]
|
||||
next.splice(result.index, 1)
|
||||
return next
|
||||
})
|
||||
setStore("part", (part: Record<string, Part[] | undefined>) => {
|
||||
if (!(input.messageID in part)) return part
|
||||
const next = { ...part }
|
||||
delete next[input.messageID]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export const createDirSyncContext = (
|
||||
directory: string,
|
||||
@@ -25,42 +177,210 @@ export const createDirSyncContext = (
|
||||
serverSDK: ReturnType<typeof createServerSdkContext>,
|
||||
) => {
|
||||
const client = serverSDK.createClient({ directory, throwOnError: true })
|
||||
const current = createMemo(() => serverSync.child(directory, { mcp: true }))
|
||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||
const data = new Proxy({} as State, {
|
||||
get(_, property: keyof State) {
|
||||
if (property === "session_working") return serverSync.session.data.session_working.bind(serverSync.session.data)
|
||||
if (sessionFields.has(property)) return serverSync.session.data[property as keyof typeof serverSync.session.data]
|
||||
return current()[0][property]
|
||||
},
|
||||
})
|
||||
const set = ((...input: unknown[]) => {
|
||||
if (typeof input[0] === "string" && sessionFields.has(input[0])) {
|
||||
return (serverSync.session.set as (...args: unknown[]) => unknown)(...input)
|
||||
}
|
||||
const result = (current()[1] as (...args: unknown[]) => unknown)(...input)
|
||||
if (input[0] === "session") current()[0].session.forEach(serverSync.session.remember)
|
||||
return result
|
||||
}) as SetStoreFunction<State>
|
||||
|
||||
const index = (sessionID: string) => {
|
||||
const session = serverSync.session.get(sessionID)
|
||||
if (!session || session.directory !== directory) return
|
||||
const [store, setStore] = current()
|
||||
const result = Binary.search(store.session, session.id, (item) => item.id)
|
||||
if (result.found) {
|
||||
setStore("session", result.index, reconcile(session))
|
||||
type Child = ReturnType<(typeof serverSync)["child"]>
|
||||
type Setter = Child[1]
|
||||
|
||||
const current = createMemo(() => serverSync.child(directory, { mcp: true }))
|
||||
const target = (directory?: string) => {
|
||||
if (!directory || directory === directory) return current()
|
||||
return serverSync.child(directory)
|
||||
}
|
||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||
const initialMessagePageSize = 80
|
||||
const historyMessagePageSize = 200
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
const inflightTodo = new Map<string, Promise<void>>()
|
||||
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
||||
const maxDirs = 30
|
||||
const seen = new Map<string, Set<string>>()
|
||||
const [meta, setMeta] = createStore({
|
||||
limit: {} as Record<string, number>,
|
||||
cursor: {} as Record<string, string | undefined>,
|
||||
complete: {} as Record<string, boolean>,
|
||||
loading: {} as Record<string, boolean>,
|
||||
})
|
||||
|
||||
const getSession = (sessionID: string) => {
|
||||
const store = current()[0]
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
if (match.found) return store.session[match.index]
|
||||
return undefined
|
||||
}
|
||||
|
||||
const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => {
|
||||
const key = keyFor(directory, sessionID)
|
||||
const list = optimistic.get(key)
|
||||
if (list) {
|
||||
list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) })
|
||||
return
|
||||
}
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => void draft.splice(result.index, 0, session)),
|
||||
optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]]))
|
||||
}
|
||||
|
||||
const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => {
|
||||
const key = keyFor(directory, sessionID)
|
||||
if (!messageID) {
|
||||
optimistic.delete(key)
|
||||
return
|
||||
}
|
||||
|
||||
const list = optimistic.get(key)
|
||||
if (!list) return
|
||||
list.delete(messageID)
|
||||
if (list.size === 0) optimistic.delete(key)
|
||||
}
|
||||
|
||||
const getOptimistic = (directory: string, sessionID: string) => [
|
||||
...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []),
|
||||
]
|
||||
|
||||
const seenFor = (directory: string) => {
|
||||
const existing = seen.get(directory)
|
||||
if (existing) {
|
||||
seen.delete(directory)
|
||||
seen.set(directory, existing)
|
||||
return existing
|
||||
}
|
||||
const created = new Set<string>()
|
||||
seen.set(directory, created)
|
||||
while (seen.size > maxDirs) {
|
||||
const first = seen.keys().next().value
|
||||
if (!first) break
|
||||
const stale = [...(seen.get(first) ?? [])]
|
||||
seen.delete(first)
|
||||
const [, setStore] = serverSync.child(first, { bootstrap: false })
|
||||
evict(first, setStore, stale)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
const clearMeta = (directory: string, sessionIDs: string[]) => {
|
||||
if (sessionIDs.length === 0) return
|
||||
for (const sessionID of sessionIDs) {
|
||||
clearOptimistic(directory, sessionID)
|
||||
}
|
||||
setMeta(
|
||||
produce((draft) => {
|
||||
for (const sessionID of sessionIDs) {
|
||||
const key = keyFor(directory, sessionID)
|
||||
delete draft.limit[key]
|
||||
delete draft.cursor[key]
|
||||
delete draft.complete[key]
|
||||
delete draft.loading[key]
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
|
||||
if (sessionIDs.length === 0) return
|
||||
clearSessionPrefetch(serverSDK.scope, directory, sessionIDs)
|
||||
for (const sessionID of sessionIDs) {
|
||||
serverSync.todo.set(sessionID, undefined)
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
dropSessionCaches(draft, sessionIDs)
|
||||
}),
|
||||
)
|
||||
clearMeta(directory, sessionIDs)
|
||||
}
|
||||
|
||||
const touch = (directory: string, setStore: Setter, sessionID: string) => {
|
||||
const stale = pickSessionCacheEvictions({
|
||||
seen: seenFor(directory),
|
||||
keep: sessionID,
|
||||
limit: SESSION_CACHE_LIMIT,
|
||||
})
|
||||
evict(directory, setStore, stale)
|
||||
}
|
||||
|
||||
const fetchMessages = async (input: { client: typeof client; sessionID: string; limit: number; before?: string }) => {
|
||||
const messages = await retry(() =>
|
||||
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
|
||||
)
|
||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||
const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
|
||||
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
|
||||
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
|
||||
return {
|
||||
session,
|
||||
part,
|
||||
cursor,
|
||||
complete: !cursor,
|
||||
}
|
||||
}
|
||||
|
||||
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
|
||||
|
||||
const loadMessages = async (input: {
|
||||
directory: string
|
||||
client: typeof client
|
||||
setStore: Setter
|
||||
sessionID: string
|
||||
limit: number
|
||||
before?: string
|
||||
mode?: "replace" | "prepend"
|
||||
}) => {
|
||||
const key = keyFor(input.directory, input.sessionID)
|
||||
if (meta.loading[key]) return
|
||||
|
||||
setMeta("loading", key, true)
|
||||
await fetchMessages(input)
|
||||
.then((page) => {
|
||||
if (!tracked(input.directory, input.sessionID)) return
|
||||
const next = mergeOptimisticPage(page, getOptimistic(input.directory, input.sessionID))
|
||||
for (const messageID of next.confirmed) {
|
||||
clearOptimistic(input.directory, input.sessionID, messageID)
|
||||
}
|
||||
const [store] = serverSync.child(input.directory, { bootstrap: false })
|
||||
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
|
||||
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
|
||||
batch(() => {
|
||||
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
|
||||
for (const p of next.part) {
|
||||
const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
|
||||
if (filtered.length) input.setStore("part", p.id, filtered)
|
||||
}
|
||||
setMeta("limit", key, message.length)
|
||||
setMeta("cursor", key, next.cursor)
|
||||
setMeta("complete", key, next.complete)
|
||||
setSessionPrefetch({
|
||||
scope: serverSDK.scope,
|
||||
directory: input.directory,
|
||||
sessionID: input.sessionID,
|
||||
limit: message.length,
|
||||
cursor: next.cursor,
|
||||
complete: next.complete,
|
||||
})
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isNotFound(error) && !tracked(input.directory, input.sessionID)) return
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
setMeta(
|
||||
produce((draft) => {
|
||||
if (!tracked(input.directory, input.sessionID)) {
|
||||
delete draft.loading[key]
|
||||
return
|
||||
}
|
||||
draft.loading[key] = false
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
set,
|
||||
get data() {
|
||||
return current()[0]
|
||||
},
|
||||
get set(): Setter {
|
||||
return current()[1]
|
||||
},
|
||||
get status() {
|
||||
return current()[0].status
|
||||
},
|
||||
@@ -69,24 +389,24 @@ export const createDirSyncContext = (
|
||||
},
|
||||
get project() {
|
||||
const store = current()[0]
|
||||
const match = Binary.search(serverSync.data.project, store.project, (project) => project.id)
|
||||
const match = Binary.search(serverSync.data.project, store.project, (p) => p.id)
|
||||
if (match.found) return serverSync.data.project[match.index]
|
||||
return undefined
|
||||
},
|
||||
session: {
|
||||
remember(session: Session) {
|
||||
serverSync.session.remember(session)
|
||||
index(session.id)
|
||||
},
|
||||
get(sessionID: string) {
|
||||
const session = serverSync.session.get(sessionID)
|
||||
if (session?.directory === directory) return session
|
||||
},
|
||||
get: getSession,
|
||||
optimistic: {
|
||||
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
|
||||
serverSync.session.optimistic.add(input)
|
||||
const _directory = input.directory ?? directory
|
||||
const [, setStore] = target(input.directory)
|
||||
setOptimistic(_directory, input.sessionID, { message: input.message, parts: input.parts })
|
||||
setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
|
||||
},
|
||||
remove(input: { directory?: string; sessionID: string; messageID: string }) {
|
||||
serverSync.session.optimistic.remove(input)
|
||||
const _directory = input.directory ?? directory
|
||||
const [, setStore] = target(input.directory)
|
||||
clearOptimistic(_directory, input.sessionID, input.messageID)
|
||||
setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
|
||||
},
|
||||
},
|
||||
addOptimisticMessage(input: {
|
||||
@@ -97,48 +417,194 @@ export const createDirSyncContext = (
|
||||
model: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
}) {
|
||||
serverSync.session.optimistic.add({
|
||||
const message: Message = {
|
||||
id: input.messageID,
|
||||
sessionID: input.sessionID,
|
||||
message: {
|
||||
id: input.messageID,
|
||||
sessionID: input.sessionID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: input.agent,
|
||||
model: { ...input.model, variant: input.variant },
|
||||
},
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: input.agent,
|
||||
model: { ...input.model, variant: input.variant },
|
||||
}
|
||||
const [, setStore] = target()
|
||||
setOptimistic(directory, input.sessionID, { message, parts: input.parts })
|
||||
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
|
||||
sessionID: input.sessionID,
|
||||
message,
|
||||
parts: input.parts,
|
||||
})
|
||||
},
|
||||
async sync(sessionID: string, options?: { force?: boolean }) {
|
||||
await serverSync.session.sync(sessionID, options)
|
||||
index(sessionID)
|
||||
async sync(sessionID: string, opts?: { force?: boolean }) {
|
||||
const [store, setStore] = serverSync.child(directory)
|
||||
const key = keyFor(directory, sessionID)
|
||||
|
||||
touch(directory, setStore, sessionID)
|
||||
|
||||
const seeded = getSessionPrefetch(serverSDK.scope, directory, sessionID)
|
||||
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
|
||||
batch(() => {
|
||||
setMeta("limit", key, seeded.limit)
|
||||
setMeta("cursor", key, seeded.cursor)
|
||||
setMeta("complete", key, seeded.complete)
|
||||
setMeta("loading", key, false)
|
||||
})
|
||||
}
|
||||
|
||||
return runInflight(inflight, key, async () => {
|
||||
const pending = getSessionPrefetchPromise(serverSDK.scope, directory, sessionID)
|
||||
if (pending) {
|
||||
await pending
|
||||
const seeded = getSessionPrefetch(serverSDK.scope, directory, sessionID)
|
||||
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
|
||||
batch(() => {
|
||||
setMeta("limit", key, seeded.limit)
|
||||
setMeta("cursor", key, seeded.cursor)
|
||||
setMeta("complete", key, seeded.complete)
|
||||
setMeta("loading", key, false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
|
||||
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
|
||||
if (cached && hasSession && !opts?.force) return
|
||||
|
||||
const limit = meta.limit[key] ?? initialMessagePageSize
|
||||
const sessionReq =
|
||||
hasSession && !opts?.force
|
||||
? Promise.resolve()
|
||||
: retry(() => client.session.get({ sessionID }))
|
||||
.then((session) => {
|
||||
if (!tracked(directory, sessionID)) return
|
||||
const data = session.data
|
||||
if (!data) return
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft, sessionID, (s) => s.id)
|
||||
if (match.found) {
|
||||
draft[match.index] = data
|
||||
return
|
||||
}
|
||||
draft.splice(match.index, 0, data)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isNotFound(error) && !tracked(directory, sessionID)) return
|
||||
throw error
|
||||
})
|
||||
|
||||
const messagesReq =
|
||||
cached && !opts?.force
|
||||
? Promise.resolve()
|
||||
: loadMessages({
|
||||
directory,
|
||||
client,
|
||||
setStore,
|
||||
sessionID,
|
||||
limit,
|
||||
})
|
||||
|
||||
await Promise.all([sessionReq, messagesReq])
|
||||
})
|
||||
},
|
||||
diff: serverSync.session.diff,
|
||||
todo: serverSync.session.todo,
|
||||
history: serverSync.session.history,
|
||||
evict(sessionID: string) {
|
||||
serverSync.session.evict(sessionID)
|
||||
async diff(sessionID: string, opts?: { force?: boolean }) {
|
||||
const [store, setStore] = serverSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
if (store.session_diff[sessionID] !== undefined && !opts?.force) return
|
||||
|
||||
const key = keyFor(directory, sessionID)
|
||||
return runInflight(inflightDiff, key, () =>
|
||||
retry(() => client.session.diff({ sessionID })).then((diff) => {
|
||||
if (!tracked(directory, sessionID)) return
|
||||
setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
|
||||
}),
|
||||
)
|
||||
},
|
||||
async todo(sessionID: string, opts?: { force?: boolean }) {
|
||||
const [store, setStore] = serverSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
const existing = store.todo[sessionID]
|
||||
const cached = serverSync.data.session_todo[sessionID]
|
||||
if (existing !== undefined) {
|
||||
if (cached === undefined) {
|
||||
serverSync.todo.set(sessionID, existing)
|
||||
}
|
||||
if (!opts?.force) return
|
||||
}
|
||||
|
||||
if (cached !== undefined) {
|
||||
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
|
||||
}
|
||||
|
||||
const key = keyFor(directory, sessionID)
|
||||
return runInflight(inflightTodo, key, () =>
|
||||
retry(() => client.session.todo({ sessionID })).then((todo) => {
|
||||
if (!tracked(directory, sessionID)) return
|
||||
const list = todo.data ?? []
|
||||
setStore("todo", sessionID, reconcile(list, { key: "id" }))
|
||||
serverSync.todo.set(sessionID, list)
|
||||
}),
|
||||
)
|
||||
},
|
||||
history: {
|
||||
more(sessionID: string) {
|
||||
const store = current()[0]
|
||||
const key = keyFor(directory, sessionID)
|
||||
if (store.message[sessionID] === undefined) return false
|
||||
if (meta.limit[key] === undefined) return false
|
||||
if (meta.complete[key]) return false
|
||||
return !!meta.cursor[key]
|
||||
},
|
||||
loading(sessionID: string) {
|
||||
const key = keyFor(directory, sessionID)
|
||||
return meta.loading[key] ?? false
|
||||
},
|
||||
async loadMore(sessionID: string, count?: number) {
|
||||
const [, setStore] = serverSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
const key = keyFor(directory, sessionID)
|
||||
const step = count ?? historyMessagePageSize
|
||||
if (meta.loading[key]) return
|
||||
if (meta.complete[key]) return
|
||||
const before = meta.cursor[key]
|
||||
if (!before) return
|
||||
|
||||
await loadMessages({
|
||||
directory,
|
||||
client,
|
||||
setStore,
|
||||
sessionID,
|
||||
limit: step,
|
||||
before,
|
||||
mode: "prepend",
|
||||
})
|
||||
},
|
||||
},
|
||||
evict(sessionID: string, _directory = directory) {
|
||||
const [, setStore] = serverSync.child(_directory)
|
||||
seenFor(_directory).delete(sessionID)
|
||||
evict(_directory, setStore, [sessionID])
|
||||
},
|
||||
fetch: async (count = 10) => {
|
||||
const [store, setStore] = current()
|
||||
setStore("limit", (value) => value + count)
|
||||
const response = await client.session.list()
|
||||
const sessions = (response.data ?? [])
|
||||
.filter((session) => !!session?.id)
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
.slice(0, store.limit)
|
||||
sessions.forEach(serverSync.session.remember)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
const [store, setStore] = serverSync.child(directory)
|
||||
setStore("limit", (x) => x + count)
|
||||
await client.session.list().then((x) => {
|
||||
const sessions = (x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
.slice(0, store.limit)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
})
|
||||
},
|
||||
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
||||
archive: async (sessionID: string) => {
|
||||
await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } })
|
||||
current()[1](
|
||||
"session",
|
||||
const [, setStore] = serverSync.child(directory)
|
||||
await client.session.update({ sessionID, time: { archived: Date.now() } })
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft, sessionID, (session) => session.id)
|
||||
if (match.found) draft.splice(match.index, 1)
|
||||
const match = Binary.search(draft.session, sessionID, (s) => s.id)
|
||||
if (match.found) draft.session.splice(match.index, 1)
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useSync } from "./sync"
|
||||
@@ -66,7 +65,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
const scope = createMemo(() => sdk().directory)
|
||||
const path = createPathHelpers(scope)
|
||||
const tabs = layout.tabs(() =>
|
||||
SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(base64Encode(sdk().directory), params.id)),
|
||||
SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(params.dir, params.id)),
|
||||
)
|
||||
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||
import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
@@ -7,25 +7,28 @@ import type {
|
||||
ProviderAuthResponse,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
import { batch } from "solid-js"
|
||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import type { ServerSession } from "../server-session"
|
||||
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||
import { loadMcpQuery } from "../server-sync"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
path: Path
|
||||
project: Project[]
|
||||
session_todo: {
|
||||
[sessionID: string]: Todo[]
|
||||
}
|
||||
provider: NormalizedProviderListResponse
|
||||
provider_auth: ProviderAuthResponse
|
||||
config: Config
|
||||
@@ -212,7 +215,6 @@ export async function bootstrapDirectory(input: {
|
||||
provider: NormalizedProviderListResponse
|
||||
}
|
||||
queryClient: QueryClient
|
||||
session?: ServerSession
|
||||
}) {
|
||||
const loading = input.store.status !== "complete"
|
||||
const seededProject = projectID(input.directory, input.global.project)
|
||||
@@ -236,30 +238,7 @@ export async function bootstrapDirectory(input: {
|
||||
.then((data) => input.setStore("agent", data)),
|
||||
() =>
|
||||
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.session.status().then(async (x) => {
|
||||
if (input.session) {
|
||||
const statuses = x.data ?? {}
|
||||
await Promise.all(
|
||||
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
|
||||
)
|
||||
input.session.set(
|
||||
"session_status",
|
||||
produce((draft) => {
|
||||
for (const sessionID of Object.keys(draft)) {
|
||||
if (statuses[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
|
||||
}
|
||||
}),
|
||||
)
|
||||
for (const [sessionID, status] of Object.entries(statuses)) {
|
||||
input.session.set("session_status", sessionID, reconcile(status))
|
||||
}
|
||||
}
|
||||
if (!input.session) input.setStore("session_status", x.data!)
|
||||
}),
|
||||
),
|
||||
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
|
||||
!seededProject &&
|
||||
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
|
||||
!seededPath &&
|
||||
@@ -284,25 +263,21 @@ export async function bootstrapDirectory(input: {
|
||||
const grouped = groupBySession(
|
||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||
)
|
||||
const warm = input.session
|
||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
|
||||
return warm.then(() =>
|
||||
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
|
||||
batch(() => {
|
||||
const current = input.session?.data.permission ?? input.store.permission
|
||||
for (const sessionID of Object.keys(current)) {
|
||||
for (const sessionID of Object.keys(input.store.permission)) {
|
||||
if (grouped[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.directory !== input.directory) continue
|
||||
if (input.session) input.session.set("permission", sessionID, [])
|
||||
if (!input.session) input.setStore("permission", sessionID, [])
|
||||
input.setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
const value = reconcile(
|
||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
input.setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
if (input.session) input.session.set("permission", sessionID, value)
|
||||
if (!input.session) input.setStore("permission", sessionID, value)
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -313,25 +288,21 @@ export async function bootstrapDirectory(input: {
|
||||
input.sdk.question.list().then((x) => {
|
||||
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
|
||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||
const warm = input.session
|
||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
|
||||
return warm.then(() =>
|
||||
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
|
||||
batch(() => {
|
||||
const current = input.session?.data.question ?? input.store.question
|
||||
for (const sessionID of Object.keys(current)) {
|
||||
for (const sessionID of Object.keys(input.store.question)) {
|
||||
if (grouped[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.directory !== input.directory) continue
|
||||
if (input.session) input.session.set("question", sessionID, [])
|
||||
if (!input.session) input.setStore("question", sessionID, [])
|
||||
input.setStore("question", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
const value = reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
input.setStore(
|
||||
"question",
|
||||
sessionID,
|
||||
reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
if (input.session) input.session.set("question", sessionID, value)
|
||||
if (!input.session) input.setStore("question", sessionID, value)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user