diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 125692363..bf4e58796 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -103,21 +103,25 @@ jobs: - name: Test changed runnable UI behavior run: >- - node --import tsx --test + node --import tsx --test --test-force-exit packages/ui/src/components/message-timeline-v2.test.ts packages/ui/src/components/provider-auth/provider-options.test.ts packages/ui/src/components/session/session-bottom-pin-intent.test.ts packages/ui/src/components/session-list-visibility.test.ts + packages/ui/src/components/tool-call/render-memory.test.ts packages/ui/src/components/unified-picker-path.test.ts packages/ui/src/components/virtual-follow-behavior.test.ts packages/ui/src/lib/filesystem-events.test.ts + packages/ui/src/lib/global-cache.test.ts packages/ui/src/lib/hooks/use-app-session-capture.test.ts packages/ui/src/lib/hooks/use-instance-metadata.test.ts packages/ui/src/lib/hooks/use-foreground-refresh.test.ts packages/ui/src/lib/launch-errors.test.ts packages/ui/src/lib/message-selection-position.test.ts packages/ui/src/lib/model-visibility.test.ts + packages/ui/src/lib/retained-size.test.ts packages/ui/src/lib/runtime-env.test.ts + packages/ui/src/lib/session-transcript-lru.test.ts packages/ui/src/lib/trailing-resync.test.ts packages/ui/src/stores/abort-created-workspace-cleanup.test.ts packages/ui/src/stores/app-session-reconciliation.test.ts @@ -129,9 +133,10 @@ jobs: packages/ui/src/stores/client-state.test.ts packages/ui/src/stores/message-v2/instance-store.test.ts packages/ui/src/stores/message-v2/message-hydration-authority.test.ts + packages/ui/src/stores/message-v2/message-window.test.ts packages/ui/src/stores/message-v2/message-status.test.ts packages/ui/src/stores/message-v2/normalizers.test.ts - packages/ui/src/stores/pty-store.test.ts + packages/ui/src/stores/shell-store.test.ts packages/ui/src/stores/session-generation-recovery.test.ts packages/ui/src/stores/session-pagination.test.ts packages/ui/src/stores/session-pending-state.test.ts @@ -144,12 +149,13 @@ jobs: node --conditions=browser --import tsx --test --test-force-exit packages/ui/src/components/form-request-tool-target.test.ts packages/ui/src/components/form-request.test.ts + packages/ui/src/components/tool-call/renderer-copy.test.ts packages/ui/src/lib/hooks/use-active-session-message-load.test.ts packages/ui/src/stores/forms.test.ts packages/ui/src/stores/instances-restore-ownership.test.ts packages/ui/src/stores/opencode-data.test.ts packages/ui/src/stores/permission-lifecycle.test.ts - packages/ui/src/stores/pty-store-reactivity.test.ts + packages/ui/src/stores/shell-store-reactivity.test.ts packages/ui/src/stores/session-actions.test.ts packages/ui/src/stores/session-native-events.test.ts packages/ui/src/stores/session-request-authority.test.ts diff --git a/.opencode/skills/codenomad-architecture-guide/SKILL.md b/.opencode/skills/codenomad-architecture-guide/SKILL.md index 340c876ac..e92791af9 100644 --- a/.opencode/skills/codenomad-architecture-guide/SKILL.md +++ b/.opencode/skills/codenomad-architecture-guide/SKILL.md @@ -20,7 +20,7 @@ description: | - There is no `packages/opencode-plugin/`. Do not restore plugin tools, plugin routes, or plugin packaging. - The server owns one shared OpenCode service through `OpenCodeSharedService` and its lease-locked discovery, launcher, process-proof, and authenticated-stop lifecycle. Proven host shutdown delegates to native `Service.stop`; WSL uses native authenticated health stop to avoid the client's cross-namespace PID fallback. Workspaces are native OpenCode `Location`/directory scopes, not separate OpenCode processes. - The UI uses generated Promise clients from `OpenCode.make()` through the CodeNomad proxy. -- OpenCode owns session APIs, native Shell (`client.session.shell`), session instructions (`client.session.instructions.entry`), and location-scoped native PTYs. Shell remains separate. The Status panel lists PTYs, refreshes on PTY events/reconnect, displays native metadata, and supports title updates and ownership-checked removal. Current installed declarations have no PTY output/read/stream or separate stop API, so output and distinct stop are unavailable; removal is the native stop action for a running PTY. +- OpenCode owns session APIs, session Shell (`client.session.shell`), session instructions (`client.session.instructions.entry`), location-scoped background Shells, and interactive PTYs. The Status panel lists `client.shell.*` records, refreshes on Shell events/reconnect, displays native metadata, and supports ownership-checked removal. Interactive `client.pty.*` terminals remain separate. - CodeNomad owns workspace lifecycle, directory authorization, Git status/diff/stage/unstage/commit, Yolo persistence/auto-replies, and `/api/events`. - V2 service startup forces `OPENCODE_DB` to `~/.local/share/opencode2/opencode.db`; never share the V1 database with V2. @@ -61,7 +61,7 @@ description: | | Public `@opencode-ai/sdk` examples | Installed experimental `@opencode-ai/client` declarations | | One `opencode serve` per workspace | One CodeNomad-managed shared service | | Per-worktree clients/processes | Root proxy client plus native location/directory inputs | -| Reintroducing `packages/opencode-plugin` or server plugin/background-process paths | Separate native Shell/instructions and native PTY management | +| Reintroducing `packages/opencode-plugin` or server plugin/background-process paths | Native session Shell/instructions, background `shell.*`, and separate interactive `pty.*` management | | OpenCode APIs for stage/commit/Yolo policy | CodeNomad routes and managers | | Hardcoded UI strings | `t()` / `tGlobal()` and every locale | diff --git a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md index bd903918d..b0c0486ca 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md +++ b/.opencode/skills/codenomad-architecture-guide/references/architecture-overview.md @@ -16,12 +16,12 @@ The server uses `packages/server/src/workspaces/opencode-service.ts` for a lease | Owner | Responsibilities | Main paths | |---|---|---| -| OpenCode V2 | Sessions, messages, permissions/questions, files, native Shell/instructions, location-scoped PTYs | latest reviewed experimental `@opencode-ai/client` `next` protocol | +| OpenCode V2 | Sessions, messages, permissions/questions, files, session Shell/instructions, background Shells, interactive PTYs | latest reviewed experimental `@opencode-ai/client` `next` protocol | | CodeNomad server | Shared service lifecycle, locations, proxy authorization, Git mutations, Yolo, auth, storage, speech, SSE multiplexing | `packages/server/src/` | | CodeNomad UI | Generated Promise clients, state reconciliation, interaction and rendering | `packages/ui/src/` | | Desktop hosts | Start CodeNomad and provide native OS integration | `packages/electron-app/`, `packages/tauri-app/` | -Native Shell remains separate from PTY management. The Status panel lists location-scoped PTYs, refreshes on PTY events/reconnect, displays native metadata, and supports title updates and ownership-checked removal. Current installed declarations have no PTY output/read/stream or separate stop endpoint; output display and a distinct stop action are unavailable, and removal is the native stop action for a running PTY. `packages/opencode-plugin/` and the server plugin/background-process integration remain deleted and must not be restored or used as extension points. +Session Shell remains separate from background Shell and PTY management. The Status panel lists location-scoped `shell.*` records, refreshes on Shell events/reconnect, displays native metadata, and supports ownership-checked removal. Output preserves native cursor pagination; interactive `pty.*` terminals remain separate. `packages/opencode-plugin/` and the server plugin/background-process integration remain deleted and must not be restored or used as extension points. ## HTTP And Events diff --git a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md index 83ba99339..61371ba01 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md +++ b/.opencode/skills/codenomad-architecture-guide/references/feature-traces.md @@ -14,9 +14,9 @@ 1. UI obtains `getRootClient(instanceId)`. 2. Conversation mode updates `client.session.instructions.entry` for the voice instruction. 3. A normal prompt calls `client.session.prompt`; `!` shell mode calls native `client.session.shell`. Native Shell remains separate from PTY management. -4. The Status panel lists native PTYs for the active location, displays their native metadata, and refreshes on PTY events and reconnect. -5. Title updates and removal use native PTY APIs; the proxy verifies native `cwd` ownership before ID-scoped operations. Removing a running PTY is its native stop action. -6. Current installed declarations have no PTY output/read/stream API or separate stop endpoint, so output display and a distinct stop action are unavailable. +4. The Status panel lists native background Shells for the active location, displays their native metadata, and refreshes on Shell events and reconnect. +5. Removal uses native Shell APIs; the proxy verifies native `cwd` ownership before every ID-scoped operation and preserves output cursors. +6. Interactive PTYs use separate `pty.*` APIs and are not background-process records. 7. The proxy checks directory/session ownership and forwards to the shared service's `/api/*` route. 8. One upstream event subscription feeds `InstanceEventBridge`, then CodeNomad `/api/events`, then UI stores. diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md index 6f6a28ee3..fd1cc9050 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-critical-behaviors.md @@ -26,13 +26,13 @@ | Concern | Owner | |---|---| -| Session/message/Shell/instructions | OpenCode native API; Shell remains separate from PTY management | -| PTY list/metadata/title/remove | Location-scoped OpenCode native API through CodeNomad ownership checks; Status UI refreshes on PTY events/reconnect | -| PTY output/distinct stop | Unavailable in current installed declarations; removal is the native stop action for a running PTY | +| Session/message/Shell/instructions | OpenCode native API; session Shell remains separate from background Shell and PTY management | +| Background Shell list/metadata/output/remove | Location-scoped OpenCode native API through CodeNomad ownership checks; Status UI refreshes on Shell events/reconnect | +| Interactive PTYs | Separate native `pty.*` API | | Service discovery/start/stop | CodeNomad hardened adapter using selected OpenCode primitives | | Workspace and directory authorization | CodeNomad | | Git status/diff and mutations | CodeNomad | | Yolo policy/persistence/auto-reply | CodeNomad | | Browser event multiplexing | CodeNomad `/api/events` | -Current installed declarations have no PTY output/read/stream API or separate stop endpoint, so the UI cannot display PTY output or offer a distinct stop action. Do not restore `@opencode-ai/sdk`, per-workspace processes, `packages/opencode-plugin`, server plugin/background-process tools, or deleted plugin/runtime file paths. +Background Shell output uses native cursor pagination; interactive PTYs remain separate. Do not restore `@opencode-ai/sdk`, per-workspace processes, `packages/opencode-plugin`, server plugin/background-process tools, or deleted plugin/runtime file paths. diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md index ac0f99b59..f04daadaa 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md @@ -22,14 +22,14 @@ const client = OpenCode.make({ baseUrl, fetch: createInstanceFetch(baseUrl) }) Use `getRootClient(instanceId)` from `packages/ui/src/stores/opencode-client.ts`. Native location/directory inputs replace the old per-worktree-client pattern. Destroy cached clients when an instance is removed. -## Native Shell, Instructions, And PTYs +## Session Shell, Background Shells, And PTYs - Shell mode calls `client.session.shell({ sessionID, command })`. - Conversation mode adds/removes `client.session.instructions.entry` before `client.session.prompt`. -- Shell remains separate from native PTY management. -- PTYs are location-scoped and listed with `client.pty.list`; the Status panel refreshes on PTY lifecycle events and reconnect, displays native metadata, and supports title updates. -- PTY ID operations are ownership-checked against the native `cwd`. Removal is the native stop action for a running PTY. -- Current installed declarations have no PTY output/read/stream API or separate stop endpoint, so output display and a distinct stop action are unavailable. +- Session Shell remains separate from background Shell and native PTY management. +- Background Shells are location-scoped and listed with `client.shell.list`; the Status panel refreshes on Shell lifecycle events and reconnect and displays native metadata. +- Shell ID operations are ownership-checked against the native `cwd`; output preserves the native cursor and removal uses `client.shell.remove`. +- Interactive terminals use separate `client.pty.*` APIs. - Keep `packages/opencode-plugin` and server plugin/background-process paths deleted. ## Event Flow diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b2f122150..ce813069c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -117,8 +117,8 @@ Then open a pull request on GitHub targeting the `dev` branch. - V2 always uses `~/.local/share/opencode2/opencode.db`. Never reuse the V1 database for V2. - OpenCode session calls use `/workspaces/:id/instance/api/*`; CodeNomad control routes and multiplexed events use `/api/*` and `/api/events`. - The proxy is method/path allowlisted, so new upstream functionality is not exposed automatically. -- Native Shell (`client.session.shell`) and prompt instructions (`client.session.instructions.entry`) remain separate from native V2 PTY management. -- Native PTYs are location-scoped and listed in the Status panel. The UI refreshes them on PTY events and reconnect, displays native metadata, and supports title updates and ownership-checked removal. Current installed declarations have no PTY output/read/stream API or separate stop endpoint, so removal is the native stop action for a running PTY. `packages/opencode-plugin` and the server plugin/background-process paths remain deleted and must not be restored. +- Shell mode (`client.session.shell`) and prompt instructions (`client.session.instructions.entry`) remain separate from background shells and interactive PTYs. +- Location-scoped background shells use `client.shell.*` and are listed in the Status panel. The UI refreshes them on Shell events and reconnect, displays native metadata, and supports ownership-checked removal. `client.pty.*` remains reserved for interactive terminals. `packages/opencode-plugin` and the server plugin/background-process paths remain deleted and must not be restored. - Native events are volatile. Reconnect handlers must refetch authoritative state instead of assuming missed events will replay. - Git mutations and Yolo policy remain CodeNomad-owned server boundaries. diff --git a/MIGRATION_V2.md b/MIGRATION_V2.md index cf7a9d7fd..791c21e7d 100644 --- a/MIGRATION_V2.md +++ b/MIGRATION_V2.md @@ -24,7 +24,7 @@ The migration removes the V1 compatibility layer rather than maintaining both in - Remove the custom `packages/opencode-plugin` package. - Remove V1 plugin communication channels and per-workspace runtime management. -- Replace interactive shell-mode requests with native V2 Shell and expose native V2 PTYs in the Status panel. +- Replace shell-mode requests with native `session.shell` and expose native background `shell.*` processes in the Status panel; keep interactive `pty.*` terminals separate. - Replace per-workspace OpenCode binary selection with one global `opencode2` binary. - Migrate the persisted V1 default command `opencode` to `opencode2` during workspace launch. - Remove message and part deletion controls because V2 currently has no equivalent API. @@ -39,7 +39,7 @@ The migration removes the V1 compatibility layer rather than maintaining both in ## Security and Service Lifecycle -- Restrict proxied Shell and PTY working directories to workspace-owned roots and Git worktrees; PTY controls also verify the native PTY `cwd` before forwarding ID-scoped requests. +- Restrict proxied Shell and PTY working directories to workspace-owned roots and Git worktrees; ID-scoped controls verify the native `cwd` before forwarding requests. - Remove CodeNomad authentication cookies before forwarding requests to OpenCode. - Prevent OpenCode `Set-Cookie` headers from being relayed to the browser. - Avoid logging unredacted secret-bearing proxy request bodies. @@ -61,7 +61,7 @@ The migration removes the V1 compatibility layer rather than maintaining both in - The proxy validates the decoded scope of native session cursors before forwarding them and supports native global Form reply/cancel routes without treating `global` as a session ID. - Before deleting a worktree, the server inventories the complete native project, evacuates affected session families with verification and rollback, and fails closed for direct API callers. One canonical folder maps to one logical workspace instead of creating non-isolated duplicates. - The current working tree retains the isolated V2 database, deferred location eviction, and proxy path/location ownership validation. -- Current installed client declarations provide native PTY list/get/create/title-or-size update/remove and lifecycle events, but no output/read/stream API and no separate stop API. Removing a running PTY is therefore the only native stop action, and PTY output is not displayed. +- Current installed client declarations provide background Shell list/get/create/output/timeout/remove and lifecycle events. Shell output pagination keeps the native cursor authoritative. Interactive PTY APIs remain separate and are not used by the background-process panel. - Local validation passes server/UI/Electron typechecks, the CI UI partitions, Electron native tests, server tests (with three platform skips), standalone server lockfile dry-run installation, UI/server/Electron builds, Tauri `cargo check --locked`, and `git diff --check`. ## Remaining Work @@ -146,9 +146,9 @@ The smoke is complete only after all of these actions succeed in the visible V2 3. Open an existing session from the session list; direct API session creation is not a substitute. 4. Send a prompt from the composer and receive its visible assistant response. 5. Reload the V2 window and confirm the workspace and session list recover. While V1 owns cross-host restore, reopen the existing V2 session from the list and confirm its messages and pending state recover correctly. -6. Exercise one PTY create/list/remove cycle through the workspace proxy, then close only the V2 process after collecting its logs. PTY creation is not currently exposed in the visible UI. +6. Exercise one background Shell create/list/output/remove cycle through the workspace proxy, then close only the V2 process after collecting its logs. Shell creation is not currently exposed in the visible UI. -Do not count direct HTTP/CDP calls as validation for workspace, session, prompt, response, or reload behavior. CDP may inspect the V2 DOM and operate visible controls, but it must follow the same controls and state transitions as a user. The PTY protocol check is the sole exception until the UI exposes creation. +Do not count direct HTTP/CDP calls as validation for workspace, session, prompt, response, or reload behavior. CDP may inspect the V2 DOM and operate visible controls, but it must follow the same controls and state transitions as a user. The background Shell protocol check is the sole exception until the UI exposes creation. ## Review Notes diff --git a/dev-docs/SUMMARY.md b/dev-docs/SUMMARY.md index 2d66bd117..e4568c538 100644 --- a/dev-docs/SUMMARY.md +++ b/dev-docs/SUMMARY.md @@ -170,7 +170,8 @@ dev-docs/ Development documentation - Database: V2 always uses `~/.local/share/opencode2/opencode.db`, separate from V1 - Events: volatile native stream with authoritative reconnect reconciliation - Proxy: explicit method/path allowlist; upstream additions are not automatic -- Shell and instructions: native session APIs, separate from PTY management -- PTYs: location-scoped native entries in Status, refreshed on PTY events/reconnect with metadata, title updates, and ownership-checked removal; current installed declarations have no output/read/stream or separate stop API, so output and distinct stop are unavailable and removal stops a running PTY +- Shell mode and instructions: native session APIs, separate from background Shell and PTY management +- Background Shells: location-scoped native `shell.*` entries in Status, refreshed on Shell events/reconnect with metadata and ownership-checked removal; output uses native cursor pagination +- PTYs: separate native interactive terminals, not background-process records - Legacy plugin/background processes: `packages/opencode-plugin` and server plugin/background-process paths remain deleted - Git mutations and Yolo: CodeNomad-owned diff --git a/dev-docs/architecture.md b/dev-docs/architecture.md index 83ad283fa..85ce0dc6c 100644 --- a/dev-docs/architecture.md +++ b/dev-docs/architecture.md @@ -57,14 +57,14 @@ Current native events include session lifecycle/output events (`session.created` | Sessions, messages, permission/question APIs | OpenCode V2 | | Shell mode | `client.session.shell` | | Conversation instructions | `client.session.instructions.entry` | -| PTY management | Location-scoped OpenCode V2 API through the ownership-checking proxy; Status panel UI | -| PTY output and distinct stop | Unavailable in the current installed declarations; removal is the native stop action for a running PTY | +| Background Shell management | Location-scoped OpenCode V2 `shell.*` API through the ownership-checking proxy; Status panel UI | +| Interactive PTY management | Separate OpenCode V2 `pty.*` API | | Workspace lifecycle and directory authorization | CodeNomad | | Git status/diff/stage/unstage/commit | CodeNomad server | | Yolo state, persistence and auto-accept | CodeNomad server | | Browser SSE multiplexing | CodeNomad server | -Native Shell remains separate from PTY management. The Status panel lists location-scoped native PTYs, refreshes on PTY events/reconnect, displays native metadata, and allows title updates and ownership-checked removal. Current installed declarations expose no PTY output/read/stream API or separate stop endpoint, so output display and a distinct stop action are unavailable. `packages/opencode-plugin` and the server plugin/background-process paths remain deleted and must not be restored. +Session Shell remains separate from background Shell and PTY management. The Status panel lists location-scoped native background Shells, refreshes on Shell events/reconnect, displays native metadata, and allows ownership-checked removal. Output requests preserve native cursor pagination. Interactive PTYs remain separate. `packages/opencode-plugin` and the server plugin/background-process paths remain deleted and must not be restored. ## Persistence diff --git a/dev-docs/technical-implementation.md b/dev-docs/technical-implementation.md index 6d2191e40..68d35f8b3 100644 --- a/dev-docs/technical-implementation.md +++ b/dev-docs/technical-implementation.md @@ -34,9 +34,9 @@ await client.session.shell({ sessionID, command }) await client.session.instructions.entry.put({ sessionID, key, value }) ``` -Shell mode and conversation instructions are upstream features and remain separate from native V2 PTYs. None requires a CodeNomad plugin. +Shell mode and conversation instructions are upstream features and remain separate from native background Shells and interactive V2 PTYs. None requires a CodeNomad plugin. -Native PTYs are location-scoped and listed in the Status panel. `packages/ui/src/stores/pty-store.ts` refreshes the list on native PTY events and reconnect, exposes native metadata, and supports title updates and removal. The proxy verifies PTY `cwd` ownership before ID-scoped operations. Current installed declarations have no PTY output/read/stream API and no separate stop endpoint: output is not displayed, and removing a running PTY is the only native stop action. +Native background Shells are location-scoped and listed in the Status panel. `packages/ui/src/stores/shell-store.ts` refreshes the list on native Shell events and reconnect, exposes native metadata, and supports removal. The proxy verifies Shell `cwd` ownership before ID-scoped operations and forwards native output cursors unchanged. Interactive `pty.*` terminals remain separate. ## Routing And Security @@ -57,7 +57,7 @@ Yolo also remains CodeNomad-owned. `AutoAcceptManager` persists policy state, ob `InstanceEventBridge` consumes the one shared `client.event.subscribe()` iterable. It maps location-scoped events to workspace IDs and publishes `instance.event` through the CodeNomad `EventBus`. This stream is volatile: reconnection does not replay a guaranteed history, so UI stores refetch sessions and pending requests and other consumers must re-read authoritative file/config state. -Use current protocol names. Session events include `session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.text.*`, `session.reasoning.*`, and `session.tool.*`; PTY refresh events include `pty.created`, `pty.updated`, `pty.exited`, and `pty.deleted`; file and config invalidations are `filesystem.changed` and `config.updated`. +Use current protocol names. Session events include `session.created`, `session.renamed`, `session.moved`, `session.status`, `session.idle`, `session.execution.*`, `session.compaction.*`, `session.text.*`, `session.reasoning.*`, and `session.tool.*`; background-process refresh events include `shell.created`, `shell.exited`, and `shell.deleted`; file and config invalidations are `filesystem.changed` and `config.updated`. ## Current Structure @@ -77,7 +77,7 @@ packages/ui/src/ stores/opencode-client.ts root client authority stores/session-api.ts session queries/lifecycle stores/session-actions.ts prompt, Shell, instructions - stores/pty-store.ts location-scoped native PTY state/actions + stores/shell-store.ts location-scoped native background Shell state/actions ``` Deleted `packages/opencode-plugin`, server plugin/background-process, and per-workspace runtime files are not architectural extension points and must not be restored. diff --git a/packages/server/src/server/__tests__/instance-proxy.test.ts b/packages/server/src/server/__tests__/instance-proxy.test.ts index 0a98d3161..b6b95d408 100644 --- a/packages/server/src/server/__tests__/instance-proxy.test.ts +++ b/packages/server/src/server/__tests__/instance-proxy.test.ts @@ -24,6 +24,7 @@ async function harness( serviceDirectory = workspacePath, pathMappings: Record = {}, ptyDirectories: Record = {}, + shellDirectories: Record = {}, ) { const upstream = Fastify() apps.push(upstream) @@ -70,6 +71,19 @@ async function harness( return { data: { id: ptyID, title: ptyID, command: "npm", args: ["run", "dev"], cwd, status: "running", pid: 42 } } }, }, + shell: { + list: async () => ({ + location: { directory: serviceDirectory, project: { id: "project", directory: serviceDirectory, canonical: serviceDirectory } }, + data: Object.entries(shellDirectories).filter((entry): entry is [string, string] => typeof entry[1] === "string").map(([id, cwd]) => ({ + id, command: "npm run dev", cwd, shell: "sh", file: "/tmp/output", status: "running" as const, pid: 42, metadata: {}, time: { started: 1 }, + })), + }), + get: async ({ id }: { id: string }) => { + const cwd = shellDirectories[id] ?? sessionDirectory + if (cwd instanceof Error) throw cwd + return { data: { id, command: "npm run dev", cwd, shell: "sh", file: "/tmp/output", status: "running", pid: 42, metadata: {}, time: { started: 1 } } } + }, + }, } as OpenCodeClient const manager: InstanceProxyWorkspaceManager = { get: () => ({ id: "workspace", path: workspacePath }) as never, @@ -128,6 +142,13 @@ describe("instance proxy location enforcement", () => { assert.doesNotMatch(bodyResponse.body, /internal-secret/) }) + it("allows the scoped native location bootstrap", async () => { + const { app } = await harness() + const response = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/location" }) + assert.equal(response.statusCode, 200) + assert.match(JSON.parse(response.body).url, /\/api\/location\?location%5Bdirectory%5D=%2Frepo/) + }) + it("authorizes project-only session lists without adding a directory", async () => { const { app } = await harness() const response = await app.inject({ @@ -191,7 +212,7 @@ describe("instance proxy location enforcement", () => { assert.equal((await app.inject({ method: "GET", - url: "/workspaces/workspace/instance/api/pty/foreign?location%5Bdirectory%5D=%2Frepo%2Fworktree", + url: "/workspaces/workspace/instance/api/pty/foreign/?location%5Bdirectory%5D=%2Frepo%2Fworktree", })).statusCode, 403) assert.equal(requestCount(), 0) }) @@ -232,6 +253,27 @@ describe("instance proxy location enforcement", () => { assert.doesNotMatch(response.body, /internal-secret/) }) + it("lists owned shells and rejects foreign shell access", async () => { + const { app, requestCount } = await harness("/repo/worktree", {}, {}, "/repo", "/repo", {}, {}, { + owned: "/repo/worktree", + foreign: "/other", + }) + + const listed = await app.inject({ + method: "GET", + url: "/workspaces/workspace/instance/api/shell?location%5Bdirectory%5D=%2Frepo%2Fworktree", + }) + assert.equal(listed.statusCode, 200) + assert.deepEqual(JSON.parse(listed.body).data.map((shell: { id: string }) => shell.id), ["owned"]) + for (const [method, path] of [["DELETE", "foreign/"], ["GET", "foreign/output/"]] as const) { + assert.equal((await app.inject({ + method, + url: `/workspaces/workspace/instance/api/shell/${path}?location%5Bdirectory%5D=%2Frepo%2Fworktree`, + })).statusCode, 403) + } + assert.equal(requestCount(), 0) + }) + it("permits only native global Forms actions without session hydration", async () => { const { app, sessionGets, requestCount } = await harness("/other") for (const action of ["reply", "cancel"]) { diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 2b73f4a61..6248037ed 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -9,7 +9,7 @@ import { connect as connectTls, type TLSSocket } from "tls" import { fetch, type Headers } from "undici" import type { Logger } from "../logger" import { WorkspaceManager } from "../workspaces/manager" -import { isPtyNotFoundError, isSessionNotFoundError, type OpenCodeClient } from "@opencode-ai/client" +import { isPtyNotFoundError, isSessionNotFoundError, isShellNotFoundError, type OpenCodeClient } from "@opencode-ai/client" import type { SettingsService } from "../settings/service" import { FileSystemBrowser } from "../filesystem/browser" @@ -665,9 +665,9 @@ async function proxyWorkspaceRequest(args: { const promptBody = replacePromptFileUris(serviceBody, translatedPromptPaths) const requestedDirectory = requestLocations.directories[0] - const ptyLocation = { directory: requestedDirectory ? translatedDirectories.get(requestedDirectory) ?? serviceDirectory : serviceDirectory } + const runtimeLocation = { directory: requestedDirectory ? translatedDirectories.get(requestedDirectory) ?? serviceDirectory : serviceDirectory } if (pathname.replace(/\/+$/, "") === "/api/pty" && request.method === "GET") { - const result = await (await workspaceManager.getSharedServiceClient()).pty.list({ location: ptyLocation }) + const result = await (await workspaceManager.getSharedServiceClient()).pty.list({ location: runtimeLocation }) const ownership = await Promise.all(result.data.map((pty) => workspaceManager.ownsDirectory(workspaceId, pty.cwd))) reply.send({ ...result, data: result.data.filter((_, index) => ownership[index]) }) return @@ -676,7 +676,7 @@ async function proxyWorkspaceRequest(args: { const ptyId = getPtyRouteId(pathname) if (ptyId) { try { - const pty = await (await workspaceManager.getSharedServiceClient()).pty.get({ ptyID: ptyId, location: ptyLocation }) + const pty = await (await workspaceManager.getSharedServiceClient()).pty.get({ ptyID: ptyId, location: runtimeLocation }) if (!(await workspaceManager.ownsDirectory(workspaceId, pty.data.cwd))) { reply.code(403).send({ error: "PTY does not belong to workspace" }) return @@ -690,6 +690,30 @@ async function proxyWorkspaceRequest(args: { } } + if (pathname.replace(/\/+$/, "") === "/api/shell" && request.method === "GET") { + const result = await (await workspaceManager.getSharedServiceClient()).shell.list({ location: runtimeLocation }) + const ownership = await Promise.all(result.data.map((shell) => workspaceManager.ownsDirectory(workspaceId, shell.cwd))) + reply.send({ ...result, data: result.data.filter((_, index) => ownership[index]) }) + return + } + + const shellId = getShellRouteId(pathname) + if (shellId) { + try { + const shell = await (await workspaceManager.getSharedServiceClient()).shell.get({ id: shellId, location: runtimeLocation }) + if (!(await workspaceManager.ownsDirectory(workspaceId, shell.data.cwd))) { + reply.code(403).send({ error: "Shell does not belong to workspace" }) + return + } + } catch (error) { + if (isShellNotFoundError(error)) { + reply.code(404).send({ error: "Shell not found" }) + return + } + throw error + } + } + const sessionId = getSessionRouteId(pathname) if (sessionId && !isGlobalFormAction(pathname, request.method)) { let session @@ -943,7 +967,11 @@ async function ownsSessionListScope( } function getPtyRouteId(pathname: string): string | null { - return pathname.match(/^\/api\/pty\/([^/]+)$/)?.[1] ?? null + return pathname.replace(/\/+$/, "").match(/^\/api\/pty\/([^/]+)$/)?.[1] ?? null +} + +function getShellRouteId(pathname: string): string | null { + return pathname.replace(/\/+$/, "").match(/^\/api\/shell\/([^/]+)(?:\/output|\/timeout)?$/)?.[1] ?? null } function buildInstanceTargetUrl(endpoint: string, pathSuffix: string | undefined): URL | null { @@ -977,7 +1005,7 @@ function hasDotSegment(value: string): boolean { function isAllowedInstanceApiRoute(method: string, pathname: string): boolean { const route = pathname.replace(/\/+$/, "") const allowed: Array<[string, RegExp]> = [ - ["GET", /^\/api\/(?:agent|command|config|integration|mcp|model|plugin|provider)$/], + ["GET", /^\/api\/(?:agent|command|config|integration|location|mcp|model|plugin|provider)$/], ["GET", /^\/api\/agent\/[^/]+$/], ["GET", /^\/api\/model\/default$/], ["GET", /^\/api\/(?:permission|question)\/request$/], @@ -986,10 +1014,12 @@ function isAllowedInstanceApiRoute(method: string, pathname: string): boolean { ["GET", /^\/api\/project$/], ["GET", /^\/api\/vcs\/status$/], ["GET", /^\/api\/fs\/(?:list|read\/.+)$/], - ["GET", /^\/api\/pty(?:\/[^/]+)?$/], + ["GET", /^\/api\/(?:pty|shell)(?:\/[^/]+(?:\/output)?)?$/], ["POST", /^\/api\/(?:pty|shell)$/], ["PUT", /^\/api\/pty\/[^/]+$/], ["DELETE", /^\/api\/pty\/[^/]+$/], + ["DELETE", /^\/api\/shell\/[^/]+$/], + ["PATCH", /^\/api\/shell\/[^/]+\/timeout$/], ["POST", /^\/api\/mcp\/[^/]+\/(?:connect|disconnect)$/], ["DELETE", /^\/api\/credential\/[^/]+$/], ["POST", /^\/api\/integration\/[^/]+\/connect\/(?:key|oauth|command)$/], diff --git a/packages/server/src/workspaces/instance-events.test.ts b/packages/server/src/workspaces/instance-events.test.ts index 05a3b8ace..8ef612e1f 100644 --- a/packages/server/src/workspaces/instance-events.test.ts +++ b/packages/server/src/workspaces/instance-events.test.ts @@ -172,22 +172,26 @@ describe("InstanceEventBridge", () => { const bridge = new InstanceEventBridge({ workspaceManager: manager, eventBus: bus, logger }) try { bus.publish({ type: "workspace.started", workspace: manager.list()[0] as any }) - await waitFor(() => received.length === 4) + await waitFor(() => received.length === 6) assert.equal(received[0].instanceId, "a") assert.deepEqual(received[0].event.location, { directory: "/repo-a" }) assert.deepEqual(received[0].event.data, { id: "p1" }) assert.equal(received[0].event.properties, undefined) assert.equal(received[1].event.data.sessionID, "session-1") assert.equal(received[1].event.properties, undefined) - assert.equal(received[2].instanceId, "a") - assert.deepEqual(received[2].event.data, { + assert.deepEqual(received.slice(2, 4).map((event) => [event.instanceId, event.event.type]), [ + ["a", "server.connected"], + ["b", "server.connected"], + ]) + assert.equal(received[4].instanceId, "a") + assert.deepEqual(received[4].event.data, { sessionID: "session-2", assistantMessageID: "message-1", ordinal: 0, delta: "hello", }) - assert.equal(received[3].instanceId, "a") - assert.equal(received[3].event.data.delta, " again") + assert.equal(received[5].instanceId, "a") + assert.equal(received[5].event.data.delta, " again") assert.equal(ownerLookups.get("/repo-a/.worktrees/feature"), 2) } finally { bridge.shutdown() @@ -280,6 +284,29 @@ describe("InstanceEventBridge", () => { } }) + it("routes locationless shell events by cwd without broadcasting ownership", async () => { + const events = [ + { type: "shell.created", data: { info: { id: "shell-1", command: "npm run dev", cwd: "/repo-b", shell: "sh", file: "/tmp/output", status: "running", metadata: {}, time: { started: 1 } } } }, + { type: "shell.exited", data: { id: "shell-1", status: "exited", exit: 0 } }, + { type: "shell.deleted", data: { id: "shell-1" } }, + ] as OpenCodeEvent[] + const workspaces = [{ id: "a", path: "/repo-a" }, { id: "b", path: "/repo-b" }] + const { manager } = locationlessManager(events, {}, workspaces) + const bus = new EventBus() + const received: any[] = [] + bus.on("instance.event", (event) => received.push(event)) + const bridge = new InstanceEventBridge({ workspaceManager: manager, eventBus: bus, logger }) + + try { + bus.publish({ type: "workspace.started", workspace: manager.list()[0] as any }) + await waitFor(() => received.length === 3) + assert.deepEqual(received.map((event) => event.instanceId), ["b", "b", "b"]) + assert.deepEqual(received.map((event) => event.event.type), ["shell.created", "shell.exited", "shell.deleted"]) + } finally { + bridge.shutdown() + } + }) + it("broadcasts an unresolvable locationless deletion", async () => { const events = [{ type: "session.deleted", data: { sessionID: "deleted" } }] as OpenCodeEvent[] const workspaces = [{ id: "a", path: "/repo-a" }, { id: "b", path: "/repo-b" }] diff --git a/packages/server/src/workspaces/instance-events.ts b/packages/server/src/workspaces/instance-events.ts index 5cd752b46..c5078ed64 100644 --- a/packages/server/src/workspaces/instance-events.ts +++ b/packages/server/src/workspaces/instance-events.ts @@ -19,6 +19,7 @@ const GLOBAL_EVENT_TYPES = new Set([ "mcp.resources.changed", "mcp.status.changed", "models-dev.refreshed", + "server.connected", ]) interface InstanceEventBridgeOptions { @@ -35,6 +36,7 @@ export class InstanceEventBridge { private readonly directoryOwners = new Map }>() private readonly sessionDirectories = new Map }>() private readonly ptyDirectories = new Map() + private readonly shellDirectories = new Map() private readonly onWorkspaceStarted = (event: { workspace: { id: string } }) => { this.clearLocationCaches() if (!this.task) this.task = this.run() @@ -96,11 +98,14 @@ export class InstanceEventBridge { private async publishEvent(event: OpenCodeEvent) { const sessionId = this.sessionId(event) const ptyId = this.ptyId(event) + const shellId = this.shellId(event) if (event.type === "session.moved" && sessionId) this.sessionDirectories.delete(sessionId) const directory = event.location?.directory ?? this.ptyInfoDirectory(event) ?? (ptyId ? this.ptyDirectories.get(ptyId) : undefined) + ?? this.shellInfoDirectory(event) + ?? (shellId ? this.shellDirectories.get(shellId) : undefined) ?? (sessionId ? await this.resolveSessionDirectory(sessionId) : undefined) if (!directory) { if (GLOBAL_EVENT_TYPES.has(event.type)) { @@ -122,11 +127,13 @@ export class InstanceEventBridge { }) } if (ptyId) this.ptyDirectories.set(ptyId, directory) + if (shellId) this.shellDirectories.set(shellId, directory) const instanceIds = await this.resolveDirectoryOwners(directory) if (instanceIds.length === 0) { if (event.type === "session.deleted" && sessionId) this.sessionDirectories.delete(sessionId) if (event.type === "pty.deleted" && ptyId) this.ptyDirectories.delete(ptyId) + if (event.type === "shell.deleted" && shellId) this.shellDirectories.delete(shellId) return } @@ -135,6 +142,7 @@ export class InstanceEventBridge { } if (event.type === "session.deleted" && sessionId) this.sessionDirectories.delete(sessionId) if (event.type === "pty.deleted" && ptyId) this.ptyDirectories.delete(ptyId) + if (event.type === "shell.deleted" && shellId) this.shellDirectories.delete(shellId) } private sessionId(event: OpenCodeEvent): string | undefined { @@ -156,6 +164,19 @@ export class InstanceEventBridge { return typeof cwd === "string" && cwd ? cwd : undefined } + private shellId(event: OpenCodeEvent): string | undefined { + if (!event.type.startsWith("shell.")) return undefined + const data = event.data as { id?: unknown; info?: { id?: unknown } } + const id = data.id ?? data.info?.id + return typeof id === "string" && id ? id : undefined + } + + private shellInfoDirectory(event: OpenCodeEvent): string | undefined { + if (event.type !== "shell.created") return undefined + const cwd = (event.data as { info?: { cwd?: unknown } }).info?.cwd + return typeof cwd === "string" && cwd ? cwd : undefined + } + private broadcastEvent(event: OpenCodeEvent): void { for (const workspace of this.options.workspaceManager.list()) { this.options.eventBus.publish({ type: "instance.event", instanceId: workspace.id, event }) @@ -200,6 +221,7 @@ export class InstanceEventBridge { this.directoryOwners.clear() this.sessionDirectories.clear() this.ptyDirectories.clear() + this.shellDirectories.clear() } private updateStatus(status: InstanceStreamStatus, reason?: string) { diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 79e29ecdf..24f8a9df7 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -589,6 +589,7 @@ const InstanceShell2: Component = (props) => { instanceId: () => props.instance.id, instanceSessions: allInstanceSessions, activeSessionId: activeSessionIdForInstance, + isActiveInstance: () => Boolean(props.isActiveInstance), }) const showEmbeddedSidebarToggle = createMemo(() => !leftPinned() && !leftOpen()) diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx index 867b9acb4..03c3cfa51 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx @@ -12,7 +12,7 @@ import { Accordion } from "@kobalte/core" import { Tooltip } from "@kobalte/core/tooltip" import Switch from "@suid/material/Switch" -import { ChevronDown, GripVertical, Info, Pencil, Trash2, XOctagon } from "lucide-solid" +import { ChevronDown, GripVertical, Info, Trash2, XOctagon } from "lucide-solid" import type { Instance } from "../../../../../types/instance" import type { Session } from "../../../../../types/session" @@ -25,8 +25,8 @@ import { togglePermissionAutoAcceptForSession } from "../../../../../stores/inst import { isPermissionAutoAcceptEnabled } from "../../../../../stores/permission-auto-accept" import { applyRightPanelItemCustomization, type RightPanelCustomization, type RightPanelSectionModule } from "../registry" import { createCoreStatusSectionManifest } from "../core-plugin" -import { ptyStore } from "../../../../../stores/ptys" -import { showConfirmDialog, showPromptDialog } from "../../../../../stores/alerts" +import { shellStore } from "../../../../../stores/shells" +import { showConfirmDialog } from "../../../../../stores/alerts" interface StatusTabProps { t: (key: string, vars?: Record) => string @@ -82,12 +82,12 @@ const SortableStatusSection: Component = (props) => const StatusTab: Component = (props) => { const isSectionExpanded = (id: string) => props.expandedItems().includes(id) - const ptyDirectory = createMemo(() => props.activeSession()?.location.directory ?? props.instance.folder) - const ptyState = createMemo(() => ptyStore.getState(props.instanceId, ptyDirectory())) + const shellDirectory = createMemo(() => props.activeSession()?.location.directory ?? props.instance.folder) + const shellState = createMemo(() => shellStore.getState(props.instanceId, shellDirectory())) createEffect(on( - () => [props.instanceId, ptyDirectory()] as const, - ([instanceId, directory]) => void ptyStore.load(instanceId, directory), + () => [props.instanceId, shellDirectory()] as const, + ([instanceId, directory]) => void shellStore.load(instanceId, directory), )) const renderYoloModeSection = () => { @@ -136,22 +136,9 @@ const StatusTab: Component = (props) => { return } - const renamePty = async (ptyId: string, currentTitle: string) => { - const title = await showPromptDialog(props.t("instanceShell.backgroundProcesses.rename.message"), { - title: props.t("instanceShell.backgroundProcesses.rename.title"), - inputLabel: props.t("instanceShell.backgroundProcesses.rename.inputLabel"), - inputDefaultValue: currentTitle, - confirmLabel: props.t("instanceShell.backgroundProcesses.actions.rename"), - }) - const trimmed = title?.trim() - if (trimmed && trimmed !== currentTitle) { - await ptyStore.updateTitle(props.instanceId, ptyDirectory(), ptyId, trimmed) - } - } - - const removePty = async (ptyId: string, title: string, running: boolean) => { + const removeShell = async (shellId: string, command: string, running: boolean) => { const confirmed = await showConfirmDialog( - props.t("instanceShell.backgroundProcesses.remove.message", { title }), + props.t("instanceShell.backgroundProcesses.remove.message", { title: command }), { title: props.t("instanceShell.backgroundProcesses.remove.title"), confirmLabel: props.t(running @@ -159,51 +146,39 @@ const StatusTab: Component = (props) => { : "instanceShell.backgroundProcesses.actions.remove"), }, ) - if (confirmed) await ptyStore.remove(props.instanceId, ptyDirectory(), ptyId) + if (confirmed) await shellStore.remove(props.instanceId, shellDirectory(), shellId) } const renderBackgroundProcesses = () => ( {props.t("instanceShell.backgroundProcesses.error")}} > 0} + when={!shellState().loading || shellState().items.length > 0} fallback={
{props.t("instanceShell.backgroundProcesses.loading")}
} > 0} + when={shellState().items.length > 0} fallback={
{props.t("instanceShell.backgroundProcesses.empty")}
} >
- - {(pty) => { - const running = () => pty.status === "running" + + {(shell) => { + const running = () => shell.status === "running" return (
-

{pty.title}

- - {[pty.command, ...pty.args].join(" ")} - +

{shell.command}

+ {shell.shell}
-
- {props.t(`instanceShell.backgroundProcesses.status.${pty.status}`)} - {props.t("instanceShell.backgroundProcesses.pid", { pid: pty.pid })} - - {props.t("instanceShell.backgroundProcesses.exitCode", { code: pty.exitCode })} + {props.t(`instanceShell.backgroundProcesses.status.${shell.status}`)} + + {props.t("instanceShell.backgroundProcesses.pid", { pid: shell.pid })} + + + {props.t("instanceShell.backgroundProcesses.exitCode", { code: shell.exit })}
-
{pty.cwd}
+
{shell.cwd}
) }} diff --git a/packages/ui/src/components/instance/shell/useSessionCache.ts b/packages/ui/src/components/instance/shell/useSessionCache.ts index 35ffe5c78..2c9cfd4fe 100644 --- a/packages/ui/src/components/instance/shell/useSessionCache.ts +++ b/packages/ui/src/components/instance/shell/useSessionCache.ts @@ -1,17 +1,15 @@ -import { createEffect, createSignal, type Accessor } from "solid-js" -import { messageStoreBus } from "../../../stores/message-v2/bus" -import { clearSessionRenderCache } from "../../message-block" -import { getLogger } from "../../../lib/logger" -import { invalidateSessionMessageLoad } from "../../../stores/session-state" - -const log = getLogger("session") - -const SESSION_CACHE_LIMIT = 5 +import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js" +import { + reconcileSessionTranscriptBudget, + setSessionTranscriptVisible, + touchSessionTranscript, +} from "../../../stores/session-transcript-memory" type SessionCacheOptions = { instanceId: Accessor instanceSessions: Accessor> activeSessionId: Accessor + isActiveInstance: Accessor } type SessionCacheState = { @@ -19,80 +17,25 @@ type SessionCacheState = { } export function useSessionCache(options: SessionCacheOptions): SessionCacheState { - const [cachedSessionIds, setCachedSessionIds] = createSignal([]) - const [pendingEvictions, setPendingEvictions] = createSignal([]) - - const evictSession = (sessionId: string) => { - if (!sessionId) return - const instanceId = options.instanceId() - log.info("Evicting cached session", { instanceId, sessionId }) - const store = messageStoreBus.getInstance(instanceId) - invalidateSessionMessageLoad(instanceId, sessionId) - store?.clearSession(sessionId, { preserveScroll: true, notify: false }) - clearSessionRenderCache(instanceId, sessionId) - } - - const scheduleEvictions = (ids: string[]) => { - if (!ids.length) return - setPendingEvictions((current) => { - const existing = new Set(current) - const next = [...current] - ids.forEach((id) => { - if (!existing.has(id)) { - next.push(id) - existing.add(id) - } - }) - return next - }) - } - - createEffect(() => { - const pending = pendingEvictions() - if (!pending.length) return - const cached = new Set(cachedSessionIds()) - const remaining: string[] = [] - pending.forEach((id) => { - if (cached.has(id)) { - remaining.push(id) - } else { - evictSession(id) - } - }) - if (remaining.length !== pending.length) { - setPendingEvictions(remaining) - } - }) - - createEffect(() => { + const cachedSessionIds = createMemo(() => { const instanceSessions = options.instanceSessions() const activeId = options.activeSessionId() + if (!options.isActiveInstance() || !activeId || activeId === "info" || !instanceSessions.has(activeId)) return [] + return [activeId] + }) - setCachedSessionIds((current) => { - const next = current.filter((id) => id !== "info" && instanceSessions.has(id)) - - const touch = (id: string | null) => { - if (!id || id === "info") return - if (!instanceSessions.has(id)) return - - const index = next.indexOf(id) - if (index !== -1) { - next.splice(index, 1) - } - next.unshift(id) - } - - touch(activeId) - - const trimmed = next.length > SESSION_CACHE_LIMIT ? next.slice(0, SESSION_CACHE_LIMIT) : next + createEffect(() => { + const instanceId = options.instanceId() + const [sessionId] = cachedSessionIds() + if (!sessionId) return + setSessionTranscriptVisible(instanceId, sessionId, true) + touchSessionTranscript(instanceId, sessionId) + reconcileSessionTranscriptBudget() + onCleanup(() => setSessionTranscriptVisible(instanceId, sessionId, false)) + }) - const trimmedSet = new Set(trimmed) - const removed = current.filter((id) => !trimmedSet.has(id)) - if (removed.length) { - scheduleEvictions(removed) - } - return trimmed - }) + onCleanup(() => { + reconcileSessionTranscriptBudget() }) return { diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 91d40793d..ca2af1514 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -1,9 +1,10 @@ -import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" +import { Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" import { useGlobalCache } from "../lib/hooks/use-global-cache" import type { TextPart, RenderCache } from "../types/message" import { getLogger } from "../lib/logger" import { copyToClipboard } from "../lib/clipboard" import { useI18n } from "../lib/i18n" +import { limitToolOutputForRender, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT } from "./tool-call/utils" const log = getLogger("session") @@ -89,6 +90,10 @@ function renderFallbackHtml(content: string): string { return escapeHtml(content).replace(/\n/g, "
") } +export function getMarkdownTextForRender(content: string): string { + return limitToolOutputForRender(content) +} + interface MarkdownProps { part: TextPart instanceId?: string @@ -158,7 +163,7 @@ export function Markdown(props: MarkdownProps) { const resolved = createMemo(() => { const part = props.part const rawText = typeof part.text === "string" ? part.text : "" - const text = decodeHtmlEntitiesLocally(rawText) + const text = decodeHtmlEntitiesLocally(getMarkdownTextForRender(rawText)) const themeKey = Boolean(props.isDark) ? "dark" : "light" const highlightEnabled = !props.disableHighlight const escapeRawHtml = Boolean(props.escapeRawHtml) @@ -346,15 +351,31 @@ export function Markdown(props: MarkdownProps) { }) return ( -
+ <> +
+ TOOL_OUTPUT_RENDER_CHARACTER_LIMIT}> + + + ) } diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index 96de64eaa..357a58361 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -20,11 +20,20 @@ import { copyToClipboard } from "../lib/clipboard" import SpeechActionButton from "./speech-action-button" import type { VisibilityPreference } from "../stores/preferences" import type { ToolState, ToolStateCompleted, ToolStateError, ToolStateRunning } from "../types/tool-state" +import { + clearInstanceMessageRenderCaches, + clearSessionMessageRenderCache, + getSessionMessageRenderCache, + peekSessionMessageRenderCache, + purgeMessageRenderCache, + extractReasoningTextForCopy, + extractReasoningTitleForRender, +} from "../lib/message-render-cache" +import { accountSessionTranscript } from "../stores/session-transcript-memory" const USER_BORDER_COLOR = "var(--message-user-border)" const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)" const NO_STEP_BORDER = "none" - const LazyToolCall = lazy(() => import("./tool-call")) function ToolCallFallback() { @@ -53,38 +62,6 @@ function extractTaskSessionId(state: ToolState | undefined): string { return typeof directId === "string" ? directId : "" } -function reasoningHasRenderableContent(part: ClientPart): boolean { - if (!part || part.type !== "reasoning") { - return false - } - const checkSegment = (segment: unknown): boolean => { - if (typeof segment === "string") { - return segment.trim().length > 0 - } - if (segment && typeof segment === "object") { - const candidate = segment as { text?: unknown; value?: unknown; content?: unknown[] } - if (typeof candidate.text === "string" && candidate.text.trim().length > 0) { - return true - } - if (typeof candidate.value === "string" && candidate.value.trim().length > 0) { - return true - } - if (Array.isArray(candidate.content)) { - return candidate.content.some((entry) => checkSegment(entry)) - } - } - return false - } - - if (checkSegment((part as any).text)) { - return true - } - if (Array.isArray((part as any).content)) { - return (part as any).content.some((entry: unknown) => checkSegment(entry)) - } - return false -} - interface TaskSessionLocation { sessionId: string instanceId: string @@ -132,47 +109,27 @@ interface CachedBlockEntry { toolKeys: string[] } -interface SessionRenderCache { - messageItems: Map - toolItems: Map - messageBlocks: Map -} - -const renderCaches = new Map() - -function makeSessionCacheKey(instanceId: string, sessionId: string) { - return `${instanceId}:${sessionId}` -} - export function clearSessionRenderCache(instanceId: string, sessionId: string) { - renderCaches.delete(makeSessionCacheKey(instanceId, sessionId)) + clearSessionMessageRenderCache(instanceId, sessionId) } -function getSessionRenderCache(instanceId: string, sessionId: string): SessionRenderCache { - const key = makeSessionCacheKey(instanceId, sessionId) - let cache = renderCaches.get(key) - if (!cache) { - cache = { - messageItems: new Map(), - toolItems: new Map(), - messageBlocks: new Map(), - } - renderCaches.set(key, cache) +function clearMessageRenderCache(instanceId: string, sessionId: string, messageIds: readonly string[]) { + const cache = peekSessionMessageRenderCache(instanceId, sessionId) + if (!cache) return + purgeMessageRenderCache(cache, messageIds) + if (cache.messageBlocks.size === 0 && cache.messageItems.size === 0 && cache.toolItems.size === 0) { + clearSessionMessageRenderCache(instanceId, sessionId) } - return cache } function clearInstanceCaches(instanceId: string) { clearRecordDisplayCacheForInstance(instanceId) - const prefix = `${instanceId}:` - for (const key of renderCaches.keys()) { - if (key.startsWith(prefix)) { - renderCaches.delete(key) - } - } + clearInstanceMessageRenderCaches(instanceId) } messageStoreBus.onInstanceDestroyed(clearInstanceCaches) +messageStoreBus.onSessionCleared(clearSessionRenderCache) +messageStoreBus.onMessagesRemoved(clearMessageRenderCache) function removeSearchMarks(root: HTMLElement) { const marks = Array.from(root.querySelectorAll("mark.session-search-match")) @@ -256,6 +213,7 @@ interface ContentDisplayItem { key: string messageId: string startPartId: string + partCount: number } interface ToolDisplayItem { @@ -271,6 +229,7 @@ interface MessageContentItemProps { store: () => InstanceMessageStore messageId: string startPartId: string + partCount: number messageIndex: number lastAssistantIndex: () => number onRevert?: (messageId: string) => void @@ -314,7 +273,7 @@ function MessageContentItem(props: MessageContentItemProps) { if (startIndex === -1) return [] const resolved: ClientPart[] = [] - for (let idx = startIndex; idx < ids.length; idx++) { + for (let idx = startIndex; idx < ids.length && resolved.length < props.partCount; idx++) { const partId = ids[idx] const part = current.parts[partId]?.data if (!part) continue @@ -477,7 +436,7 @@ interface StepDisplayItem { type ReasoningDisplayItem = { type: "reasoning" key: string - part: ClientPart + text: string messageInfo?: MessageInfo durationMs?: number showAgentMeta?: boolean @@ -499,8 +458,9 @@ type CompactionDisplayItem = { type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | StepDisplayItem | ReasoningDisplayItem | CompactionDisplayItem interface MessageDisplayBlock { - record: MessageRecord + messageId: string items: MessageBlockItem[] + truncated: boolean } interface MessageBlockProps { @@ -526,12 +486,22 @@ export default function MessageBlock(props: MessageBlockProps) { const { t } = useI18n() const record = createMemo(() => props.store().getMessage(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) - const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId) + const sessionCache = getSessionMessageRenderCache(props.instanceId, props.sessionId) as { + messageItems: Map + toolItems: Map + messageBlocks: Map + } let blockRef: HTMLDivElement | undefined const isSearchResult = () => Boolean(props.searchResultMessageIds?.().has(props.messageId)) const activeSearchMatch = () => props.activeSearchMatch?.() ?? null const isActiveSearchResult = () => activeSearchMatch()?.messageId === props.messageId let lastInlineScrolledSearchMatchId: string | null = null + const handleContentRendered = () => { + props.onContentRendered?.() + accountSessionTranscript(props.instanceId, props.sessionId) + } + + onCleanup(() => accountSessionTranscript(props.instanceId, props.sessionId)) createEffect(() => { const query = props.searchQuery?.() ?? "" @@ -581,7 +551,8 @@ export default function MessageBlock(props: MessageBlockProps) { // Only capture info after cache check fails - ensures fresh data on version bump const info = untrack(messageInfo) - const { orderedParts } = buildRecordDisplayData(props.instanceId, current) + const displayData = buildRecordDisplayData(props.instanceId, current) + const { orderedParts } = displayData const items: MessageBlockItem[] = [] const blockContentKeys: string[] = [] const blockToolKeys: string[] = [] @@ -610,8 +581,11 @@ export default function MessageBlock(props: MessageBlockProps) { key: segmentKey, messageId: current.id, startPartId, + partCount: pendingParts.length, } sessionCache.messageItems.set(segmentKey, cached) + } else { + cached.partCount = pendingParts.length } items.push(cached) @@ -689,7 +663,8 @@ export default function MessageBlock(props: MessageBlockProps) { if (part.type === "reasoning") { flushContent() - if (props.showThinking() && reasoningHasRenderableContent(part)) { + const text = typeof (part as any).text === "string" ? (part as any).text : "" + if (props.showThinking() && text.trim().length > 0) { const partId = part.id ?? "" const key = `${current.id}:${partId || partIndex}:reasoning` const showAgentMeta = current.role === "assistant" && !agentMetaAttached @@ -699,7 +674,7 @@ export default function MessageBlock(props: MessageBlockProps) { items.push({ type: "reasoning", key, - part, + text, messageInfo: info, durationMs: inferReasoningDurationMs(orderedParts, part, info, current.status), showAgentMeta, @@ -717,13 +692,14 @@ export default function MessageBlock(props: MessageBlockProps) { flushContent() - const resultBlock: MessageDisplayBlock = { record: current, items } + const resultBlock: MessageDisplayBlock = { messageId: current.id, items, truncated: displayData.truncated } sessionCache.messageBlocks.set(current.id, { signature: cacheSignature, block: resultBlock, contentKeys: blockContentKeys.slice(), toolKeys: blockToolKeys.slice(), }) + accountSessionTranscript(props.instanceId, props.sessionId) const messagePrefix = `${current.id}:` for (const [key] of sessionCache.messageItems) { @@ -753,13 +729,13 @@ export default function MessageBlock(props: MessageBlockProps) { return ( {(resolvedBlock) => ( - +
{ blockRef = el }} class="message-stream-block" - data-message-id={resolvedBlock().record.id} + data-message-id={resolvedBlock().messageId} data-search-result={isSearchResult() ? "true" : undefined} data-search-active={isActiveSearchResult() ? "true" : undefined} > @@ -772,12 +748,13 @@ export default function MessageBlock(props: MessageBlockProps) { sessionId={props.sessionId} store={props.store} messageId={(item() as ContentDisplayItem).messageId} - startPartId={(item() as ContentDisplayItem).startPartId} + startPartId={(item() as ContentDisplayItem).startPartId} + partCount={(item() as ContentDisplayItem).partCount} messageIndex={props.messageIndex} lastAssistantIndex={props.lastAssistantIndex} onRevert={props.onRevert} onFork={props.onFork} - onContentRendered={props.onContentRendered} + onContentRendered={handleContentRendered} /> @@ -792,7 +769,7 @@ export default function MessageBlock(props: MessageBlockProps) { store={props.store} messageId={toolItem.messageId} partId={toolItem.partId} - onContentRendered={props.onContentRendered} + onContentRendered={handleContentRendered} />
@@ -820,7 +797,7 @@ export default function MessageBlock(props: MessageBlockProps) { instanceId={props.instanceId} sessionId={props.sessionId} messageId={props.messageId} - onContentRendered={props.onContentRendered} + onContentRendered={handleContentRendered} /> @@ -835,7 +812,11 @@ export default function MessageBlock(props: MessageBlockProps) { extractReasoningTextForCopy( + props.store().getMessage((item() as ReasoningDisplayItem).messageId)?.parts[(item() as ReasoningDisplayItem).partId]?.data, + )} messageInfo={(item() as ReasoningDisplayItem).messageInfo} durationMs={(item() as ReasoningDisplayItem).durationMs} instanceId={props.instanceId} @@ -843,13 +824,30 @@ export default function MessageBlock(props: MessageBlockProps) { messageId={(item() as ReasoningDisplayItem).messageId} showAgentMeta={(item() as ReasoningDisplayItem).showAgentMeta} defaultExpanded={(item() as ReasoningDisplayItem).defaultExpanded} - onContentRendered={props.onContentRendered} + onContentRendered={handleContentRendered} forceExpanded={activeSearchMatch()?.partId === (item() as ReasoningDisplayItem).partId} /> )} + +
+ {t("toolCall.output.truncated")} + +
+
)} @@ -857,6 +855,13 @@ export default function MessageBlock(props: MessageBlockProps) { ) } +function orderedMessageParts(record: MessageRecord): ClientPart[] { + return record.partIds.flatMap((partId) => { + const part = record.parts[partId]?.data + return part ? [part] : [] + }) +} + interface StepCardProps { kind: "start" | "finish" part: ClientPart @@ -1061,7 +1066,9 @@ function formatCostValue(value: number) { } interface ReasoningCardProps { - part: ClientPart + text: string + partId: string + copyText: () => string messageInfo?: MessageInfo durationMs?: number instanceId: string @@ -1169,51 +1176,9 @@ function ReasoningCard(props: ReasoningCardProps) { return modelID } - const reasoningText = () => { - const part = props.part as any - if (!part) return "" - - const stringifySegment = (segment: unknown): string => { - if (typeof segment === "string") { - return segment - } - if (segment && typeof segment === "object") { - const obj = segment as { text?: unknown; value?: unknown; content?: unknown[] } - const pieces: string[] = [] - if (typeof obj.text === "string") { - pieces.push(obj.text) - } - if (typeof obj.value === "string") { - pieces.push(obj.value) - } - if (Array.isArray(obj.content)) { - pieces.push(obj.content.map((entry) => stringifySegment(entry)).join("\n")) - } - return pieces.filter((piece) => piece && piece.trim().length > 0).join("\n") - } - return "" - } - - const textValue = stringifySegment(part.text) - if (textValue.trim().length > 0) { - return textValue - } - if (Array.isArray(part.content)) { - return part.content.map((entry: unknown) => stringifySegment(entry)).join("\n") - } - return "" - } - - const extractedTitle = () => { - const firstLine = reasoningText() - .split(/\r?\n/) - .map((line: string) => line.trim()) - .find((line: string) => line.length > 0) - if (!firstLine) return "" + const reasoningText = () => props.text - const match = firstLine.match(/^\*\*([^*]+)\*\*/) - return match?.[1]?.trim() ?? "" - } + const extractedTitle = () => extractReasoningTitleForRender(reasoningText()) const thoughtDurationTitle = () => { const duration = props.durationMs @@ -1255,14 +1220,14 @@ function ReasoningCard(props: ReasoningCardProps) { const toggle = () => setExpanded((prev) => !prev) const speech = useSpeech({ - id: () => `${props.instanceId}:${props.sessionId}:${props.messageId}:${(props.part as any)?.id ?? "reasoning"}`, + id: () => `${props.instanceId}:${props.sessionId}:${props.messageId}:${props.partId || "reasoning"}`, text: reasoningText, }) const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech() const handleCopyReasoning = async () => { - const text = reasoningText() + const text = props.copyText() if (!text.trim()) return await copyToClipboard(text) } @@ -1294,7 +1259,7 @@ function ReasoningCard(props: ReasoningCardProps) { return (
- - +
) } @@ -402,6 +406,7 @@ function ToolCallDetails(props: { error={permissionError} renderDiff={renderDiffContent} fallbackSessionId={() => props.sessionId} + onApprovalBlockedChange={setPermissionApprovalBlocked} onRespond={(permission, sessionId, response, message) => void handlePermissionResponse(permission, response, message)} /> ) @@ -418,6 +423,10 @@ function ToolCallDetails(props: { await copyToClipboard(text) } + const copyToolInput = async (event: MouseEvent) => { + await copyIoText(event, formatToolInputForCopy(props.toolInput())?.text) + } + const outputWrapTitle = () => props.outputWrapEnabled() ? props.t("toolCall.diff.disableWordWrap") @@ -431,6 +440,7 @@ function ToolCallDetails(props: { copyText?: () => string | null | undefined copyTitle?: () => string copyAriaLabel?: () => string + onCopy?: (event: MouseEvent) => void actions?: () => JSXElement wrapToggle?: () => boolean | undefined }) => ( @@ -447,18 +457,16 @@ function ToolCallDetails(props: { {(actions) => {actions()}} - - {(copyText) => ( - - )} + + @@ -539,7 +547,7 @@ function ToolCallDetails(props: { language: () => toolInputDisplay()?.language, expanded: props.inputSectionExpanded, onToggle: props.toggleInputSection, - copyText: () => toolInputDisplay()?.copyText, + onCopy: copyToolInput, copyTitle: () => props.t("toolCall.io.copyInputTitle"), copyAriaLabel: () => props.t("toolCall.io.copyInputAriaLabel"), }) @@ -564,6 +572,7 @@ function ToolCallDetails(props: { expanded: props.outputSectionExpanded, onToggle: props.toggleOutputSection, copyText: () => outputChrome().copyText, + onCopy: canCopyOutput() ? (event) => void copyIoText(event, resolveOutputCopyText()) : undefined, copyTitle: () => props.t("toolCall.io.copyOutputTitle"), copyAriaLabel: () => props.t("toolCall.io.copyOutputAriaLabel"), actions: () => outputChrome().actions, @@ -697,7 +706,9 @@ export default function ToolCall(props: ToolCallProps) { const hasToolInput = createMemo(() => { const input = toolInput() - return input && Object.keys(input).length > 0 + if (!input) return false + for (const key in input) if (Object.prototype.hasOwnProperty.call(input, key)) return true + return false }) const [toolCallRootEl, setToolCallRootEl] = createSignal() @@ -710,11 +721,7 @@ export default function ToolCall(props: ToolCallProps) { if (override !== undefined) return override return diagnosticsDefaultExpanded() } - const diagnosticsEntries = createMemo(() => { - const state = toolState() - if (!state) return [] - return extractDiagnostics(state) - }) + const diagnosticsView = createMemo(() => extractDiagnosticsView(toolState())) const toggleInputSection = () => { setInputSectionOverride((prev) => { @@ -824,6 +831,7 @@ export default function ToolCall(props: ToolCallProps) { } const toolTypeLabel = createMemo(() => toolName()) + const renderedToolTypeLabel = createMemo(() => limitToolTitleForRender(toolTypeLabel())) const headerTitleDetail = createMemo(() => { const rawTitle = renderToolTitle().trim() @@ -845,9 +853,10 @@ export default function ToolCall(props: ToolCallProps) { const detail = headerTitleDetail() return [typeLabel, detail].filter(Boolean).join(" ") }) + const renderedHeaderTitleDetail = createMemo(() => limitToolTitleForRender(headerTitleDetail())) - const headerCopyText = createMemo(() => headerOutputChrome().copyText || "") - const canCopyHeaderOutput = () => headerCopyText().length > 0 + const headerCopyText = () => headerOutputChrome().copyText || headerOutputChrome().getCopyText?.() || "" + const canCopyHeaderOutput = () => headerOutputChrome().hasCopyText ?? Boolean(headerOutputChrome().copyText || headerOutputChrome().getCopyText) const canToggleOutputWrap = () => Boolean(headerOutputChrome().wrapToggle) const outputWrapTitle = () => outputWrapEnabled() @@ -960,8 +969,8 @@ export default function ToolCall(props: ToolCallProps) { > - {toolTypeLabel()} - + {renderedToolTypeLabel()} + {(detail) => {detail()}} @@ -976,7 +985,7 @@ export default function ToolCall(props: ToolCallProps) { aria-label={t("toolCall.header.copyOutputAriaLabel")} title={t("toolCall.header.copyOutputTitle")} > - + @@ -1059,17 +1068,17 @@ export default function ToolCall(props: ToolCallProps) { /> - + 0 || diagnosticsView().truncated) && diagnosticsVisibility() !== "hidden"}> {renderDiagnosticsSection( t, - diagnosticsEntries(), + diagnosticsView(), diagnosticsExpanded(), () => setDiagnosticsOverride((prev) => { const current = prev === undefined ? diagnosticsDefaultExpanded() : prev return !current }), - diagnosticFileName(diagnosticsEntries()), + diagnosticFileName(diagnosticsView().entries), )} diff --git a/packages/ui/src/components/tool-call/ansi-render.tsx b/packages/ui/src/components/tool-call/ansi-render.tsx index d7eb2e7de..b378920d0 100644 --- a/packages/ui/src/components/tool-call/ansi-render.tsx +++ b/packages/ui/src/components/tool-call/ansi-render.tsx @@ -3,6 +3,7 @@ import type { RenderCache } from "../../types/message" import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi" import { escapeHtml } from "../../lib/text-render-utils" import type { AnsiRenderOptions, ToolScrollHelpers } from "./types" +import { limitToolOutputForRender } from "./utils" type AnsiRenderCache = RenderCache & { hasAnsi: boolean } @@ -129,6 +130,7 @@ export function createAnsiContentRenderer(params: { return null } + const content = limitToolOutputForRender(options.content) const size = options.size || "default" const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}` const cacheHandle = options.variant === "running" ? params.ansiRunningCache : params.ansiFinalCache @@ -143,7 +145,6 @@ export function createAnsiContentRenderer(params: { let nextCache: AnsiRenderCache if (isRunningVariant) { - const content = options.content const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource if (resetStreaming) { @@ -182,12 +183,12 @@ export function createAnsiContentRenderer(params: { runningAnsiSource = nextCache.text cacheHandle.set(nextCache) } else { - if (cached && cached.text === options.content) { + if (cached && cached.text === content) { nextCache = { ...cached, mode } } else { - const detectedAnsi = hasAnsi(options.content) - const html = detectedAnsi ? ansiToHtml(options.content) : escapeHtml(options.content) - nextCache = { text: options.content, html, mode, hasAnsi: detectedAnsi } + const detectedAnsi = hasAnsi(content) + const html = detectedAnsi ? ansiToHtml(content) : escapeHtml(content) + nextCache = { text: content, html, mode, hasAnsi: detectedAnsi } cacheHandle.set(nextCache) } } diff --git a/packages/ui/src/components/tool-call/diagnostic-selection.ts b/packages/ui/src/components/tool-call/diagnostic-selection.ts new file mode 100644 index 000000000..304563099 --- /dev/null +++ b/packages/ui/src/components/tool-call/diagnostic-selection.ts @@ -0,0 +1,12 @@ +export function selectSeverityBounded(values: readonly T[], rank: (value: T) => number | undefined, limit: number): T[] { + const buckets: T[][] = [[], [], []] + const scanLimit = Math.max(limit, limit * 100) + for (let index = 0; index < values.length && index < scanLimit; index += 1) { + const value = values[index] + const valueRank = rank(value) + if (valueRank === undefined) continue + const bucket = buckets[Math.max(0, Math.min(2, valueRank))]! + if (bucket.length < limit) bucket.push(value) + } + return buckets.flat().slice(0, limit) +} diff --git a/packages/ui/src/components/tool-call/diagnostics-section.tsx b/packages/ui/src/components/tool-call/diagnostics-section.tsx index b4b9057f7..71af68a49 100644 --- a/packages/ui/src/components/tool-call/diagnostics-section.tsx +++ b/packages/ui/src/components/tool-call/diagnostics-section.tsx @@ -1,14 +1,30 @@ import { For, Show } from "solid-js" -import type { DiagnosticEntry } from "./diagnostics" +import { Copy } from "lucide-solid" +import { hasDiagnosticMessages, type DiagnosticsMap, type DiagnosticsView } from "./diagnostics" +import { copyToClipboard } from "../../lib/clipboard" +import { formatUnknownForCopy } from "./utils" + +export function DiagnosticsPayloadAccess(props: { diagnostics: DiagnosticsMap; truncated: boolean; t: (key: string, params?: Record) => string }) { + return ( +
+ + {props.t(props.truncated ? "toolCall.output.truncated" : "toolCall.diagnostics.title")} + + +
+ ) +} export function renderDiagnosticsSection( t: (key: string, params?: Record) => string, - entries: DiagnosticEntry[], + view: DiagnosticsView, expanded: boolean, toggle: () => void, fileLabel: string, ) { - if (entries.length === 0) return null + if (!hasDiagnosticMessages(view.diagnostics)) return null return (
+
- + {(entry) => (
diff --git a/packages/ui/src/components/tool-call/diagnostics.ts b/packages/ui/src/components/tool-call/diagnostics.ts index f7f50dcab..5bd739cad 100644 --- a/packages/ui/src/components/tool-call/diagnostics.ts +++ b/packages/ui/src/components/tool-call/diagnostics.ts @@ -1,6 +1,7 @@ import type { ToolState } from "../../types/tool-state" import { getRelativePath, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./utils" import { tGlobal } from "../../lib/i18n" +import { selectSeverityBounded } from "./diagnostic-selection" interface LspRangePosition { line?: number @@ -26,12 +27,50 @@ export interface DiagnosticEntry { label: string icon: string message: string + messageTruncated: boolean filePath: string displayPath: string line: number column: number } +export interface DiagnosticsView { + diagnostics: DiagnosticsMap + entries: DiagnosticEntry[] + key?: string + truncated: boolean +} + +function diagnosticListHasMessages(list: unknown): boolean { + if (!Array.isArray(list)) return false + for (let index = 0; index < list.length && index < 10_000; index += 1) { + if (typeof list[index]?.message === "string") return true + } + return list.length > 10_000 +} + +const DIAGNOSTIC_SCAN_LIMIT = 10_000 + +export function hasDiagnosticMessages(diagnostics: DiagnosticsMap): boolean { + let scanned = 0 + let scannedKeys = 0 + for (const key in diagnostics) { + if (!Object.prototype.hasOwnProperty.call(diagnostics, key)) continue + scannedKeys += 1 + if (scannedKeys > DIAGNOSTIC_SCAN_LIMIT) return true + const list = diagnostics[key] + if (!Array.isArray(list)) continue + const remaining = DIAGNOSTIC_SCAN_LIMIT - scanned + if (remaining <= 0) return true + for (let index = 0; index < list.length && index < remaining; index += 1) { + scanned += 1 + if (typeof list[index]?.message === "string") return true + } + if (list.length > remaining) return true + } + return false +} + export function normalizeDiagnosticPath(path: string) { return path.replace(/\\/g, "/") } @@ -48,26 +87,45 @@ function getSeverityMeta(tone: DiagnosticEntry["tone"]) { return { label: tGlobal("toolCall.diagnostics.severity.info.short"), icon: "i", rank: 2 } } -export function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] { - if (!state) return [] +export function extractDiagnosticsView(state: ToolState | undefined): DiagnosticsView { + if (!state) return buildDiagnosticView({}, []) const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state) - if (!supportsMetadata) return [] + if (!supportsMetadata) return buildDiagnosticView({}, []) const metadata = (state.metadata || {}) as Record const input = (state.input || {}) as Record const diagnosticsMap = metadata?.diagnostics as DiagnosticsMap | undefined - if (!diagnosticsMap) return [] + if (!diagnosticsMap) return buildDiagnosticView({}, []) - return buildDiagnosticEntries(diagnosticsMap, [input.filePath, metadata.filePath, metadata.filepath, input.path].map((value) => + const view = buildDiagnosticView(diagnosticsMap, [input.filePath, metadata.filePath, metadata.filepath, input.path].map((value) => typeof value === "string" ? value : undefined, )) + let scanned = 0 + let scannedKeys = 0 + for (const key in diagnosticsMap) { + if (!Object.prototype.hasOwnProperty.call(diagnosticsMap, key)) continue + scannedKeys += 1 + if (scannedKeys > DIAGNOSTIC_SCAN_LIMIT) return { ...view, truncated: true } + const list = diagnosticsMap[key] + if (!Array.isArray(list)) continue + const remaining = DIAGNOSTIC_SCAN_LIMIT - scanned + if (remaining <= 0) return { ...view, truncated: true } + for (let index = 0; index < list.length && index < remaining; index += 1) { + scanned += 1 + if (key !== view.key && typeof list[index]?.message === "string") return { ...view, truncated: true } + } + if (list.length > remaining) return { ...view, truncated: true } + } + return view } -export function resolveDiagnosticsKey(diagnostics: DiagnosticsMap, preferredPaths: Array): string | undefined { - if (Object.keys(diagnostics).length === 0) return undefined +export function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] { + return extractDiagnosticsView(state).entries +} +export function resolveDiagnosticsKey(diagnostics: DiagnosticsMap, preferredPaths: Array): string | undefined { const normalizedPreferred = preferredPaths - .filter((value): value is string => typeof value === "string" && value.length > 0) + .filter((value): value is string => typeof value === "string" && value.length > 0 && value.length <= 4_096) .map((value) => normalizeDiagnosticPath(value)) if (normalizedPreferred.length === 0) return undefined @@ -76,7 +134,15 @@ export function resolveDiagnosticsKey(diagnostics: DiagnosticsMap, preferredPath if (diagnostics[preferred]) return preferred } - const keys = Object.keys(diagnostics) + const keys: string[] = [] + let scannedKeys = 0 + for (const key in diagnostics) { + if (!Object.prototype.hasOwnProperty.call(diagnostics, key)) continue + scannedKeys += 1 + if (scannedKeys > 10_000) break + if (key.length > 4_096) continue + keys.push(key) + } for (const preferred of normalizedPreferred) { const direct = keys.find((key) => normalizeDiagnosticPath(key) === preferred) @@ -94,29 +160,38 @@ export function resolveDiagnosticsKey(diagnostics: DiagnosticsMap, preferredPath return undefined } -export function buildDiagnosticEntries(diagnostics: DiagnosticsMap, preferredPaths: Array): DiagnosticEntry[] { +export function buildDiagnosticView(diagnostics: DiagnosticsMap, preferredPaths: Array): DiagnosticsView { const key = resolveDiagnosticsKey(diagnostics, preferredPaths) - if (!key) return [] + if (!key) return { diagnostics, entries: [], truncated: false } const list = diagnostics[key] - if (!Array.isArray(list) || list.length === 0) return [] + if (!Array.isArray(list) || list.length === 0) return { diagnostics, entries: [], key, truncated: false } + const limit = 100 const entries: DiagnosticEntry[] = [] const normalizedPath = normalizeDiagnosticPath(key) - for (let index = 0; index < list.length; index++) { - const diagnostic = list[index] + const selected = selectSeverityBounded( + list, + (diagnostic) => diagnostic && typeof diagnostic.message === "string" + ? getSeverityMeta(determineSeverityTone(diagnostic.severity)).rank + : undefined, + limit, + ) + for (const diagnostic of selected) { + const index = entries.length if (!diagnostic || typeof diagnostic.message !== "string") continue const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined) const severityMeta = getSeverityMeta(tone) const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0 const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0 entries.push({ - id: `${normalizedPath}-${index}-${diagnostic.message}`, + id: String(index), severity: severityMeta.rank, tone, label: severityMeta.label, icon: severityMeta.icon, - message: diagnostic.message, + message: diagnostic.message.slice(0, 2_000), + messageTruncated: diagnostic.message.length > 2_000, filePath: normalizedPath, displayPath: getRelativePath(normalizedPath), line, @@ -124,7 +199,16 @@ export function buildDiagnosticEntries(diagnostics: DiagnosticsMap, preferredPat }) } - return entries.sort((a, b) => a.severity - b.severity) + return { + diagnostics, + entries, + key, + truncated: list.length > entries.length || entries.some((entry) => entry.messageTruncated), + } +} + +export function buildDiagnosticEntries(diagnostics: DiagnosticsMap, preferredPaths: Array): DiagnosticEntry[] { + return buildDiagnosticView(diagnostics, preferredPaths).entries } export function diagnosticFileName(entries: DiagnosticEntry[]) { diff --git a/packages/ui/src/components/tool-call/diff-render.tsx b/packages/ui/src/components/tool-call/diff-render.tsx index 88c6fca90..287bed38f 100644 --- a/packages/ui/src/components/tool-call/diff-render.tsx +++ b/packages/ui/src/components/tool-call/diff-render.tsx @@ -1,11 +1,11 @@ -import { Suspense, createEffect, createMemo, createSignal, lazy, onMount, type Accessor, type JSXElement } from "solid-js" +import { Show, Suspense, createEffect, createMemo, createSignal, lazy, onMount, type Accessor, type JSXElement } from "solid-js" import type { ToolState } from "../../types/tool-state" import useMediaQuery from "@suid/material/useMediaQuery" import { AlignJustify, Copy, Split, WrapText } from "lucide-solid" import type { RenderCache } from "../../types/message" import type { DiffViewMode } from "../../stores/preferences" import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types" -import { getRelativePath } from "./utils" +import { getRelativePath, limitToolOutputForRender, shouldRenderDiffPayloadAsPlainText } from "./utils" import { getCacheEntry } from "../../lib/global-cache" import { copyToClipboard } from "../../lib/clipboard" @@ -65,6 +65,8 @@ export function createDiffContentRenderer(params: { } function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null { + const renderedDiffText = limitToolOutputForRender(payload.diffText) + const diffWasTruncated = shouldRenderDiffPayloadAsPlainText(payload) const relativePath = payload.filePath ? getRelativePath(payload.filePath) : "" const toolbarLabel = options?.label || (relativePath ? params.t("toolCall.diff.label.withPath", { path: relativePath }) @@ -100,7 +102,7 @@ export function createDiffContentRenderer(params: { const cached = getCacheEntry(cacheEntryParams) if ( cached - && cached.text === payload.diffText + && cached.text === renderedDiffText && cached.theme === themeKey && cached.mode === currentMode() && cached.wrap === currentWrap() @@ -127,6 +129,10 @@ export function createDiffContentRenderer(params: { ? params.t("toolCall.diff.disableWordWrap") : params.t("toolCall.diff.enableWordWrap") const copyPatchTitle = () => params.t("toolCall.diff.copyPatch") + const copyFullDiff = async () => { + const copiedDiff = payload.copyText ?? payload.diffText + if (await copyToClipboard(copiedDiff)) options?.onFullDiffAccess?.(copiedDiff) + } const handleDiffRendered = () => { params.handleScrollRendered() @@ -146,38 +152,42 @@ export function createDiffContentRenderer(params: { - - + + + +
- {cachedHtml() ? ( + {diffWasTruncated ? ( +
{renderedDiffText}
+ ) : cachedHtml() ? ( ) : ( - {payload.diffText}}> + {renderedDiffText}}> { + const payload = { diffText: "x".repeat(10_001) } + assert.equal(isPermissionApprovalBlocked(payload, false), true) + assert.equal(isPermissionApprovalBlocked(payload, true), false) + assert.equal(isPermissionApprovalBlocked({ diffText: "small" }, false), false) + assert.deepEqual(getPermissionDiffPayload({ metadata: { diff: payload.diffText, path: "/file" } } as any), { diffText: payload.diffText, filePath: "/file" }) +}) diff --git a/packages/ui/src/components/tool-call/permission-block.tsx b/packages/ui/src/components/tool-call/permission-block.tsx index 7d4f05f91..dcb545d97 100644 --- a/packages/ui/src/components/tool-call/permission-block.tsx +++ b/packages/ui/src/components/tool-call/permission-block.tsx @@ -6,9 +6,22 @@ import { useI18n } from "../../lib/i18n" import { PERMISSION_REJECT_REASON_MAX_LENGTH } from "./permission-constants" import type { DiffPayload, DiffRenderOptions } from "./types" import { getRelativePath } from "./utils" +import { shouldRenderDiffPayloadAsPlainText } from "./utils" type PermissionResponse = "once" | "always" | "reject" +export function isPermissionApprovalBlocked(payload: DiffPayload | null, fullDiffReviewed: boolean): boolean { + return Boolean(payload && shouldRenderDiffPayloadAsPlainText(payload) && !fullDiffReviewed) +} + +export function getPermissionDiffPayload(permission: PermissionRequest | undefined): DiffPayload | null { + if (!permission) return null + const metadata = (permission.metadata ?? {}) as Record + const diffText = typeof metadata.diff === "string" ? metadata.diff : null + const filePath = typeof metadata.filePath === "string" ? metadata.filePath : typeof metadata.path === "string" ? metadata.path : undefined + return diffText?.trim() ? { diffText, filePath } : null +} + export type PermissionToolBlockProps = { permission: Accessor active: Accessor @@ -17,34 +30,21 @@ export type PermissionToolBlockProps = { onRespond: (permission: PermissionRequest, sessionId: string, response: PermissionResponse, message?: string) => void | Promise renderDiff: (payload: DiffPayload, options?: DiffRenderOptions) => JSXElement | null fallbackSessionId: Accessor + onApprovalBlockedChange?: (blocked: boolean) => void } export function PermissionToolBlock(props: PermissionToolBlockProps) { const { t } = useI18n() const [rejectReason, setRejectReason] = createSignal("") + const [fullDiffReviewed, setFullDiffReviewed] = createSignal(false) createEffect(() => { props.permission()?.id setRejectReason("") + setFullDiffReviewed(false) }) - const diffPayload = () => { - const permission = props.permission() - if (!permission) return null - const metadata = (permission.metadata ?? {}) as Record - const diffValue = typeof metadata.diff === "string" ? (metadata.diff as string) : null - const diffPathRaw = (() => { - if (typeof metadata.filePath === "string") { - return metadata.filePath as string - } - if (typeof metadata.path === "string") { - return metadata.path as string - } - return undefined - })() - if (!diffValue || diffValue.trim().length === 0) return null - return { diffText: diffValue, filePath: diffPathRaw } satisfies DiffPayload - } + const diffPayload = () => getPermissionDiffPayload(props.permission()) const respond = (response: PermissionResponse, message?: string) => { const permission = props.permission() @@ -56,6 +56,11 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) { const confirmReject = () => { respond("reject", rejectReason().trim() || undefined) } + const approvalBlocked = () => { + const payload = diffPayload() + return isPermissionApprovalBlocked(payload, fullDiffReviewed()) + } + createEffect(() => props.onApprovalBlockedChange?.(approvalBlocked())) return ( @@ -80,6 +85,7 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) { label: payload().filePath ? t("toolCall.permission.requestedDiff.withPath", { path: getRelativePath(payload().filePath || "") }) : t("toolCall.permission.requestedDiff.label"), + onFullDiffAccess: () => setFullDiffReviewed(true), })}
)} @@ -105,7 +111,7 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
- +
Enter {t("toolCall.permission.shortcuts.allowOnce")} diff --git a/packages/ui/src/components/tool-call/render-memory.test.ts b/packages/ui/src/components/tool-call/render-memory.test.ts new file mode 100644 index 000000000..339dc0a04 --- /dev/null +++ b/packages/ui/src/components/tool-call/render-memory.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { + limitToolOutputForRender, + limitToolTitleForRender, + shouldRenderDiffAsPlainText, + TOOL_OUTPUT_RENDER_CHARACTER_LIMIT, + TOOL_TITLE_RENDER_CHARACTER_LIMIT, +} from "./utils.ts" + +test("bounds representative tool output, title, and diff rendering", () => { + const tail = "COPY_TAIL" + const rendered = limitToolOutputForRender(`${"x".repeat(20_000)}${tail}`) + assert.equal(rendered.length, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT) + assert.equal(rendered.includes(tail), false) + assert.equal(limitToolTitleForRender("x".repeat(20_000)).length, TOOL_TITLE_RENDER_CHARACTER_LIMIT) + assert.equal(shouldRenderDiffAsPlainText("x".repeat(TOOL_OUTPUT_RENDER_CHARACTER_LIMIT + 1)), true) +}) diff --git a/packages/ui/src/components/tool-call/renderer-copy.test.ts b/packages/ui/src/components/tool-call/renderer-copy.test.ts new file mode 100644 index 000000000..1504ec285 --- /dev/null +++ b/packages/ui/src/components/tool-call/renderer-copy.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { bashRenderer } from "./renderers/bash.tsx" + +test("resolves complete tool output only through lazy copy", () => { + const full = `${"x".repeat(10_000)}COPY_TAIL` + const chrome = bashRenderer.getOutputChrome?.({ + toolName: () => "bash", + t: (key: string) => key, + toolState: () => ({ status: "completed", input: { command: "echo" }, metadata: {}, output: full }), + } as any) + + assert.equal(chrome?.copyText, undefined) + assert.equal(chrome?.getCopyText?.()?.endsWith(full), true) +}) diff --git a/packages/ui/src/components/tool-call/renderers/apply-patch-data.ts b/packages/ui/src/components/tool-call/renderers/apply-patch-data.ts new file mode 100644 index 000000000..13637922f --- /dev/null +++ b/packages/ui/src/components/tool-call/renderers/apply-patch-data.ts @@ -0,0 +1,140 @@ +export type ApplyPatchFile = { + filePath?: string + relativePath?: string + type?: string + diff?: string + patch?: string +} + +export const APPLY_PATCH_FILE_RENDER_LIMIT = 20 +const APPLY_PATCH_SCAN_LIMIT = 10_000 + +export function getApplyPatchPathLabel(path: string, limit = 384): string { + const tail = path.slice(-(limit + 1)).replace(/\\/g, "/") + const separator = tail.lastIndexOf("/") + const label = separator >= 0 ? tail.slice(separator + 1) : tail + return label.length <= limit ? label : `...${label.slice(-(limit - 3))}` +} + +export function* getApplyPatchDiagnosticPaths(diagnostics: Record): Generator { + for (const path in diagnostics) if (Object.prototype.hasOwnProperty.call(diagnostics, path)) yield path +} + +function getApplyPatchDiff(file: ApplyPatchFile): string { + return typeof file.diff === "string" ? file.diff : typeof file.patch === "string" ? file.patch : "" +} + +function probeApplyPatchCopyText(file: ApplyPatchFile, limit: number) { + const diff = getApplyPatchDiff(file) + const prefix = diff.slice(0, Math.max(0, limit)) + return { hasContent: /\S/.test(prefix), scanned: prefix.length, truncated: prefix.length < diff.length } +} + +export function hasApplyPatchCopyText(files: ApplyPatchFile[], scanLimit = APPLY_PATCH_SCAN_LIMIT): boolean { + let characters = 0 + let index = 0 + for (; index < files.length && index < scanLimit; index += 1) { + const probe = probeApplyPatchCopyText(files[index], scanLimit - characters) + if (probe.hasContent || probe.truncated) return true + characters += probe.scanned + } + return index < files.length +} + +export function getApplyPatchCopyText(files: ApplyPatchFile[], limit = Number.POSITIVE_INFINITY): string { + const diffs: string[] = [] + let characters = 0 + for (const file of files) { + const remaining = limit - characters + if (remaining <= 0) break + const diff = getApplyPatchDiff(file) + const copyText = diff.slice(0, remaining) + if (!copyText.trim()) { + if (copyText.length < diff.length) break + continue + } + diffs.push(copyText) + characters += Math.min(diff.length, remaining) + if (diff.length > remaining) break + } + return diffs.join("\n") +} + +export function getApplyPatchCopyOutput(files: ApplyPatchFile[], fallback: unknown): string | null { + return getApplyPatchCopyText(files) || (typeof fallback === "string" && fallback.length > 0 ? fallback : null) +} + +export function getApplyPatchCopyAccess(files: ApplyPatchFile[], fallback: unknown) { + if (hasApplyPatchCopyText(files)) { + return { language: "diff" as const, getCopyText: () => getApplyPatchCopyOutput(files, fallback), hasCopyText: true as const } + } + if (typeof fallback !== "string" || fallback.length === 0) return undefined + return { language: "text" as const, getCopyText: () => fallback, hasCopyText: true as const } +} + +export function getApplyPatchRenderData(files: ApplyPatchFile[], fileLimit: number, characterLimit: number, sourceTruncated = false) { + const rendered: Array<{ file: ApplyPatchFile; diffText: string }> = [] + let characters = 0 + let scannedCharacters = 0 + let truncated = sourceTruncated + for (const file of files) { + if (rendered.length >= fileLimit || characters >= characterLimit) { + truncated = true + break + } + const remaining = characterLimit - characters + const fullDiff = getApplyPatchDiff(file) + const probe = probeApplyPatchCopyText(file, Math.min(APPLY_PATCH_SCAN_LIMIT - scannedCharacters, remaining)) + scannedCharacters += probe.scanned + const firstContent = probe.hasContent ? fullDiff.slice(0, probe.scanned).search(/\S/) : -1 + const diffText = firstContent >= 0 ? fullDiff.slice(firstContent, firstContent + remaining) : "" + rendered.push({ file, diffText }) + characters += diffText.length + if (probe.truncated || (firstContent >= 0 && fullDiff.length - firstContent > remaining)) truncated = true + } + return { rendered, truncated } +} + +export function getApplyPatchFilesForRender(files: ApplyPatchFile[], diagnosticPaths: Iterable, limit = APPLY_PATCH_FILE_RENDER_LIMIT) { + const normalize = (path: string) => path.replace(/\\/g, "/") + const matchesPath = (left: string, right: string) => left === right || left.endsWith(`/${right}`) || right.endsWith(`/${left}`) + const paths: Array<{ raw: string; normalized: string }> = [] + let diagnosticPathsTruncated = false + for (const path of diagnosticPaths) { + if (paths.length >= limit) { + diagnosticPathsTruncated = true + break + } + paths.push({ raw: path, normalized: normalize(path) }) + } + const rendered: ApplyPatchFile[] = [] + const knownPaths: string[] = [] + let scannedFiles = 0 + let scannedCharacters = 0 + for (let fileIndex = 0; fileIndex < files.length && rendered.length < limit; fileIndex += 1) { + const file = files[fileIndex] + scannedFiles += 1 + const normalizedFilePaths = [file.filePath, file.relativePath] + .filter((path): path is string => typeof path === "string") + .map(normalize) + const matchesDiagnostic = normalizedFilePaths.some((path) => paths.some((diagnostic) => matchesPath(path, diagnostic.normalized))) + const probe = probeApplyPatchCopyText(file, APPLY_PATCH_SCAN_LIMIT - scannedCharacters) + scannedCharacters += probe.scanned + if (probe.hasContent || probe.truncated || matchesDiagnostic) { + rendered.push(file) + knownPaths.push(...normalizedFilePaths) + } + if (scannedFiles >= 10_000) break + } + for (const path of paths) { + if (rendered.length >= limit) break + if (!knownPaths.some((knownPath) => matchesPath(knownPath, path.normalized))) { + rendered.push({ filePath: path.raw }) + knownPaths.push(path.normalized) + } + } + return { + files: rendered, + truncated: scannedFiles < files.length || diagnosticPathsTruncated, + } +} diff --git a/packages/ui/src/components/tool-call/renderers/apply-patch.tsx b/packages/ui/src/components/tool-call/renderers/apply-patch.tsx index 65b2bd111..bdf041fec 100644 --- a/packages/ui/src/components/tool-call/renderers/apply-patch.tsx +++ b/packages/ui/src/components/tool-call/renderers/apply-patch.tsx @@ -1,16 +1,10 @@ import { For, Show, createMemo } from "solid-js" import type { ToolRenderer } from "../types" -import { getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils" -import { buildDiagnosticEntries, type DiagnosticEntry, type DiagnosticsMap } from "../diagnostics" +import { getToolName, isToolStateCompleted, limitToolOutputForRender, limitToolTitleForRender, readToolStatePayload, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT } from "../utils" +import { buildDiagnosticView, hasDiagnosticMessages, type DiagnosticEntry, type DiagnosticsMap } from "../diagnostics" +import { DiagnosticsPayloadAccess } from "../diagnostics-section" import { getApplyPatchToolSearchText } from "../search-text" - -type ApplyPatchFile = { - filePath?: string - relativePath?: string - type?: string - diff?: string - patch?: string -} +import { APPLY_PATCH_FILE_RENDER_LIMIT, getApplyPatchCopyAccess, getApplyPatchCopyText, getApplyPatchDiagnosticPaths, getApplyPatchFilesForRender, getApplyPatchPathLabel, getApplyPatchRenderData, hasApplyPatchCopyText, type ApplyPatchFile } from "./apply-patch-data" function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string; t: (key: string, params?: Record) => string }) { return ( @@ -68,62 +62,108 @@ export const applyPatchRenderer: ToolRenderer = { const payload = readToolStatePayload(state) const files = Array.isArray((payload.metadata as any).files) ? ((payload.metadata as any).files as ApplyPatchFile[]) : [] - const diffs = files - .map((file) => (typeof file.diff === "string" ? file.diff : typeof file.patch === "string" ? file.patch : "")) - .filter((diff) => diff.trim().length > 0) - if (diffs.length > 0) { - return { language: "diff", copyText: diffs.join("\n"), suppressInnerHeader: false } - } - - const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null - if (!fallback) return undefined - return { language: "text", copyText: fallback, wrapToggle: true, suppressInnerHeader: true } + const fallback = isToolStateCompleted(state) && typeof state.output === "string" && state.output.length > 0 ? state.output : null + const access = getApplyPatchCopyAccess(files, fallback) + if (!access) return undefined + return access.language === "diff" + ? { ...access, suppressInnerHeader: false } + : { ...access, wrapToggle: true, suppressInnerHeader: true } }, renderBody({ toolState, renderDiff, renderMarkdown, t }) { const state = toolState() if (!state || state.status === "pending") return null const payload = readToolStatePayload(state) - const files = createMemo(() => { - const list = (payload.metadata as any).files - return Array.isArray(list) ? (list as ApplyPatchFile[]) : [] - }) const diagnosticsMap = createMemo(() => { const value = (payload.metadata as any).diagnostics return value && typeof value === "object" ? (value as DiagnosticsMap) : {} }) + const allFiles = createMemo(() => { + const list = (payload.metadata as any).files + return getApplyPatchFilesForRender(Array.isArray(list) ? list as ApplyPatchFile[] : [], getApplyPatchDiagnosticPaths(diagnosticsMap())) + }) + const renderData = createMemo(() => getApplyPatchRenderData(allFiles().files, APPLY_PATCH_FILE_RENDER_LIMIT, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT, allFiles().truncated)) + const files = createMemo(() => renderData().rendered) + const fallback = createMemo(() => { + if (!isToolStateCompleted(state) || hasApplyPatchCopyText(files().map(({ file }) => file))) return null + return typeof state.output === "string" && state.output.length > 0 ? state.output : null + }) + const diagnosticViews = createMemo(() => { + let remaining = 100 + return files().map(({ file }) => { + const view = buildDiagnosticView(diagnosticsMap(), [file.filePath, file.relativePath]) + const entries = view.entries.slice(0, remaining) + remaining -= entries.length + return { ...view, entries, truncated: view.truncated || entries.length < view.entries.length } + }) + }) + const diagnosticsTruncated = createMemo(() => { + const views = diagnosticViews() + const renderedKeys = new Set(views.map((view) => view.key).filter(Boolean)) + if (views.some((view) => view.truncated)) return true + let scanned = 0 + let scannedKeys = 0 + for (const key in diagnosticsMap()) { + if (!Object.prototype.hasOwnProperty.call(diagnosticsMap(), key)) continue + scannedKeys += 1 + if (scannedKeys > 10_000) return true + const list = diagnosticsMap()[key] + if (!renderedKeys.has(key) && Array.isArray(list)) { + const remaining = 10_000 - scanned + if (remaining <= 0) return true + for (let index = 0; index < list.length && index < remaining; index += 1) { + scanned += 1 + if (typeof list[index]?.message === "string") return true + } + if (list.length > remaining) return true + } + } + return false + }) - if (files().length === 0) { - const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null - if (!fallback) return null - return renderMarkdown({ content: fallback, size: "large", disableHighlight: state.status === "running" }) - } + if (files().length === 0 && !fallback() && !renderData().truncated) return null return (
+ + {(content) => renderMarkdown({ content: limitToolOutputForRender(content()), size: "large", disableHighlight: state.status === "running" })} + - {(file, index) => { + {(renderedFile, index) => { + const file = renderedFile.file const labelBase = file.relativePath || file.filePath || t("toolCall.applyPatch.fileFallback", { number: index() + 1 }) - const diffText = typeof file.diff === "string" ? file.diff : typeof file.patch === "string" ? file.patch : "" + const label = getApplyPatchPathLabel(labelBase) + const fullDiff = typeof file.diff === "string" ? file.diff : typeof file.patch === "string" ? file.patch : "" + const diffText = renderedFile.diffText const filePath = typeof file.filePath === "string" ? file.filePath : file.relativePath - const entries = createMemo(() => buildDiagnosticEntries(diagnosticsMap(), [file.filePath, file.relativePath])) + const entries = createMemo(() => diagnosticViews()[index()]?.entries ?? []) return (
0}> {renderDiff( - { diffText, filePath }, + { diffText, copyText: fullDiff, filePath }, { - label: t("toolCall.diff.label.withPath", { path: getRelativePath(labelBase) }), - cacheKey: `apply_patch:${labelBase}:${index()}`, + label: limitToolTitleForRender(t("toolCall.diff.label.withPath", { path: label })), + cacheKey: `apply_patch:${index()}`, }, )} - +
) }}
+ +
{t("toolCall.output.truncated")}
+
+ +
+
+ +
+
+
) }, diff --git a/packages/ui/src/components/tool-call/renderers/bash.tsx b/packages/ui/src/components/tool-call/renderers/bash.tsx index 2123760f7..1d2a815a6 100644 --- a/packages/ui/src/components/tool-call/renderers/bash.tsx +++ b/packages/ui/src/components/tool-call/renderers/bash.tsx @@ -1,7 +1,7 @@ import { Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js" import type { ToolState } from "../../../types/tool-state" import type { ToolRenderer, ToolScrollHelpers } from "../types" -import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, formatUnknownForCopy, formatUnknownForRender, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, limitToolOutputForRender, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" import { createStableAnsiStreamUpdater } from "../ansi-render" import { ansiToHtml, hasAnsi } from "../../../lib/ansi" @@ -99,7 +99,7 @@ function getBashCopyText(state: ToolState | undefined): string { const { input, metadata } = readToolStatePayload(state) const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : "" - const outputResult = formatUnknown( + const outputResult = formatUnknownForCopy( isToolStateCompleted(state) ? state.output : (isToolStateRunning(state) || isToolStateError(state)) && metadata.output @@ -122,8 +122,8 @@ function BashToolBody(props: { if (!current || current.status === "pending") return "" const { input, metadata } = readToolStatePayload(current) - const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : "" - const outputResult = formatUnknown( + const command = typeof input.command === "string" && input.command.length > 0 ? limitToolOutputForRender(`$ ${input.command}`) : "" + const outputResult = formatUnknownForRender( isToolStateCompleted(current) ? current.output : (isToolStateRunning(current) || isToolStateError(current)) && metadata.output @@ -132,10 +132,11 @@ function BashToolBody(props: { ) return [command, outputResult?.text].filter(Boolean).join("\n") }) + const renderedContent = createMemo(() => limitToolOutputForRender(joinedContent())) const finalMarkdown = createMemo(() => { const current = state() - const content = joinedContent() + const content = renderedContent() if (!current || current.status === "pending" || current.status === "running" || content.length === 0) { return null } @@ -147,7 +148,7 @@ function BashToolBody(props: { const finalAnsiHtml = createMemo(() => { const current = state() - const content = joinedContent() + const content = renderedContent() if (!current || current.status === "pending" || current.status === "running" || content.length === 0) { return null } @@ -169,7 +170,7 @@ function BashToolBody(props: { } > - + ) @@ -196,9 +197,14 @@ export const bashRenderer: ToolRenderer = { return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}` }, getOutputChrome({ toolState }) { - const text = getBashCopyText(toolState()) - if (!text) return undefined - return { language: "bash", copyText: text, wrapToggle: true, suppressInnerHeader: true } + const state = toolState() + if (!state || state.status === "pending") return undefined + const { input, metadata } = readToolStatePayload(state) + const output = isToolStateCompleted(state) ? state.output : (isToolStateRunning(state) || isToolStateError(state)) ? metadata.output : undefined + const hasCopyText = (typeof input.command === "string" && input.command.length > 0) + || (output !== undefined && output !== null && output !== "" && (!Array.isArray(output) || output.length > 0)) + if (!hasCopyText) return undefined + return { language: "bash", getCopyText: () => getBashCopyText(state), hasCopyText: true, wrapToggle: true, suppressInnerHeader: true } }, renderBody({ toolState, renderMarkdown, scrollHelpers, onContentRendered }) { return diff --git a/packages/ui/src/components/tool-call/renderers/default.tsx b/packages/ui/src/components/tool-call/renderers/default.tsx index f19682675..78aaad939 100644 --- a/packages/ui/src/components/tool-call/renderers/default.tsx +++ b/packages/ui/src/components/tool-call/renderers/default.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, formatUnknown, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, formatUnknownForCopy, formatUnknownForRender, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils" import { getDefaultToolSearchText } from "../search-text" export const defaultRenderer: ToolRenderer = { @@ -16,12 +16,13 @@ export const defaultRenderer: ToolRenderer = { ? metadata.output : metadata.diff ?? metadata.preview ?? input.content - const result = formatUnknown(primaryOutput) - if (!result) return undefined + if (primaryOutput === undefined || primaryOutput === null || primaryOutput === "" || (Array.isArray(primaryOutput) && primaryOutput.length === 0)) return undefined + const result = formatUnknownForRender(primaryOutput) return { - language: result.language ?? "text", - copyText: result.text, + language: result?.language ?? "text", + getCopyText: () => formatUnknownForCopy(primaryOutput)?.text ?? null, + hasCopyText: true, wrapToggle: true, suppressInnerHeader: true, } @@ -37,7 +38,7 @@ export const defaultRenderer: ToolRenderer = { ? metadata.output : metadata.diff ?? metadata.preview ?? input.content - const result = formatUnknown(primaryOutput) + const result = formatUnknownForRender(primaryOutput) if (!result) return null const content = ensureMarkdownContent(result.text, result.language, true) diff --git a/packages/ui/src/components/tool-call/renderers/edit.tsx b/packages/ui/src/components/tool-call/renderers/edit.tsx index b4f84fb1d..0c715675f 100644 --- a/packages/ui/src/components/tool-call/renderers/edit.tsx +++ b/packages/ui/src/components/tool-call/renderers/edit.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, limitToolOutputForRender, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" import { getDiffToolSearchText } from "../search-text" @@ -21,7 +21,7 @@ export const editRenderer: ToolRenderer = { const diffPayload = extractDiffPayload(toolName(), state) if (diffPayload) { - return { language: "diff", copyText: diffPayload.diffText, suppressInnerHeader: false } + return { language: "diff", getCopyText: () => diffPayload.diffText, suppressInnerHeader: false } } const { metadata } = readToolStatePayload(state) @@ -29,7 +29,7 @@ export const editRenderer: ToolRenderer = { const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null const copyText = diffText || fallback if (!copyText) return undefined - return { language: "diff", copyText, wrapToggle: true, suppressInnerHeader: true } + return { language: "diff", getCopyText: () => copyText, wrapToggle: true, suppressInnerHeader: true } }, renderBody({ toolState, toolName, renderDiff, renderMarkdown }) { const state = toolState() @@ -43,7 +43,8 @@ export const editRenderer: ToolRenderer = { const { metadata } = readToolStatePayload(state) const diffText = typeof metadata.diff === "string" ? metadata.diff : null const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null - const content = ensureMarkdownContent(diffText || fallback, "diff", true) + const value = diffText || fallback + const content = ensureMarkdownContent(value ? limitToolOutputForRender(value) : value, "diff", true) if (!content) return null return renderMarkdown({ content, size: "large", disableHighlight: state.status === "running" }) diff --git a/packages/ui/src/components/tool-call/renderers/patch.tsx b/packages/ui/src/components/tool-call/renderers/patch.tsx index 356bba5a6..cd3a42fe6 100644 --- a/packages/ui/src/components/tool-call/renderers/patch.tsx +++ b/packages/ui/src/components/tool-call/renderers/patch.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, limitToolOutputForRender, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" import { getDiffToolSearchText } from "../search-text" @@ -21,7 +21,7 @@ export const patchRenderer: ToolRenderer = { const diffPayload = extractDiffPayload(toolName(), state) if (diffPayload) { - return { language: "diff", copyText: diffPayload.diffText, suppressInnerHeader: false } + return { language: "diff", getCopyText: () => diffPayload.diffText, suppressInnerHeader: false } } const { metadata } = readToolStatePayload(state) @@ -29,7 +29,7 @@ export const patchRenderer: ToolRenderer = { const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null const copyText = diffText || fallback if (!copyText) return undefined - return { language: "diff", copyText, wrapToggle: true, suppressInnerHeader: true } + return { language: "diff", getCopyText: () => copyText, wrapToggle: true, suppressInnerHeader: true } }, renderBody({ toolState, toolName, renderDiff, renderMarkdown }) { const state = toolState() @@ -43,7 +43,8 @@ export const patchRenderer: ToolRenderer = { const { metadata } = readToolStatePayload(state) const diffText = typeof metadata.diff === "string" ? metadata.diff : null const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null - const content = ensureMarkdownContent(diffText || fallback, "diff", true) + const value = diffText || fallback + const content = ensureMarkdownContent(value ? limitToolOutputForRender(value) : value, "diff", true) if (!content) return null return renderMarkdown({ content, size: "large", disableHighlight: state.status === "running" }) diff --git a/packages/ui/src/components/tool-call/renderers/read.tsx b/packages/ui/src/components/tool-call/renderers/read.tsx index ff37e2e85..133e8bc3b 100644 --- a/packages/ui/src/components/tool-call/renderers/read.tsx +++ b/packages/ui/src/components/tool-call/renderers/read.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, limitToolOutputForRender, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" import { getReadToolSearchText } from "../search-text" @@ -48,7 +48,7 @@ export const readRenderer: ToolRenderer = { const preview = typeof metadata.preview === "string" ? metadata.preview : null if (!preview) return undefined const language = inferLanguageFromPath(getReadPath(input)) ?? "text" - return { language, copyText: preview, wrapToggle: true, suppressInnerHeader: true } + return { language, getCopyText: () => preview, wrapToggle: true, suppressInnerHeader: true } }, renderBody({ toolState, renderMarkdown }) { const state = toolState() @@ -56,7 +56,7 @@ export const readRenderer: ToolRenderer = { const { metadata, input } = readToolStatePayload(state) const preview = typeof metadata.preview === "string" ? metadata.preview : null const language = inferLanguageFromPath(getReadPath(input)) - const content = ensureMarkdownContent(preview, language, true) + const content = ensureMarkdownContent(preview ? limitToolOutputForRender(preview) : preview, language, true) if (!content) return null return renderMarkdown({ content, disableHighlight: state.status === "running" }) }, diff --git a/packages/ui/src/components/tool-call/renderers/skill.tsx b/packages/ui/src/components/tool-call/renderers/skill.tsx index adaa2cc66..ee783e0f1 100644 --- a/packages/ui/src/components/tool-call/renderers/skill.tsx +++ b/packages/ui/src/components/tool-call/renderers/skill.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, formatUnknown, getToolName } from "../utils" +import { ensureMarkdownContent, formatUnknownForCopy, formatUnknownForRender, getToolName } from "../utils" import { getDefaultToolSearchText } from "../search-text" export const skillRenderer: ToolRenderer = { @@ -12,15 +12,14 @@ export const skillRenderer: ToolRenderer = { const state = toolState() if (!state || state.status !== "completed") return undefined - const output = formatUnknown(state.output)?.text ?? null - if (!output) return undefined - return { copyText: output, suppressInnerHeader: true } + if (state.output === undefined || state.output === null || state.output === "" || (Array.isArray(state.output) && state.output.length === 0)) return undefined + return { getCopyText: () => formatUnknownForCopy(state.output)?.text ?? null, hasCopyText: true, suppressInnerHeader: true } }, renderBody({ toolState, renderMarkdown }) { const state = toolState() if (!state || state.status !== "completed") return null - const output = formatUnknown(state.output)?.text ?? null + const output = formatUnknownForRender(state.output)?.text ?? null const content = ensureMarkdownContent(output, undefined, false) if (!content) return null return
{renderMarkdown({ content })}
diff --git a/packages/ui/src/components/tool-call/renderers/task-summary.ts b/packages/ui/src/components/tool-call/renderers/task-summary.ts new file mode 100644 index 000000000..a0563cfb3 --- /dev/null +++ b/packages/ui/src/components/tool-call/renderers/task-summary.ts @@ -0,0 +1,53 @@ +import { limitToolTitleForRender } from "../utils" + +export const TASK_STEP_RENDER_LIMIT = 200 + +export function isTaskStepListTruncated(count: number): boolean { + return count > TASK_STEP_RENDER_LIMIT +} + +export function isTaskScanTruncated(...sources: boolean[]): boolean { + return sources.some(Boolean) +} + +export function resolveTaskStepTruncation(childSourceActive: boolean, childTruncated: boolean, legacyTruncated: boolean): boolean { + return childTruncated || (!childSourceActive && legacyTruncated) +} + +export function getTaskOutputCopyText(state: unknown): string | null { + const output = (state as { output?: unknown } | null | undefined)?.output + return typeof output === "string" && output.length > 0 ? output : null +} + +export function stringifyChildTaskSteps( + messageIds: readonly string[], + getMessage: (messageId: string) => { partIds: readonly string[]; parts: Record } | undefined, +): string { + const steps: unknown[] = [] + for (const messageId of messageIds) { + const message = getMessage(messageId) + if (!message) continue + for (const partId of message.partIds) { + const part = message.parts[partId]?.data as { type?: unknown } | undefined + if (part?.type === "tool") steps.push(part) + } + } + return JSON.stringify(steps, null, 2) +} + +export function getLegacyTaskSummary(summary: unknown) { + const entries = Array.isArray(summary) ? summary : [] + return { + entries, + renderedEntries: entries.slice(-TASK_STEP_RENDER_LIMIT), + truncated: isTaskStepListTruncated(entries.length), + } +} + +export function stringifyLegacyTaskSummary(summary: unknown): string { + return JSON.stringify(getLegacyTaskSummary(summary).entries, null, 2) +} + +export function getTruncatedTaskStepTitleCopyText(title: string): string | null { + return limitToolTitleForRender(title) === title ? null : title +} diff --git a/packages/ui/src/components/tool-call/renderers/task.tsx b/packages/ui/src/components/tool-call/renderers/task.tsx index 7af160e35..d8327f578 100644 --- a/packages/ui/src/components/tool-call/renderers/task.tsx +++ b/packages/ui/src/components/tool-call/renderers/task.tsx @@ -1,11 +1,20 @@ -import { For, Index, Show, createEffect, createMemo, createSignal, untrack } from "solid-js" +import { For, Index, Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js" +import { Copy } from "lucide-solid" import type { ToolState } from "../../../types/tool-state" import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, limitToolOutputForRender, limitToolTitleForRender, readToolStatePayload } from "../utils" import { messageStoreBus } from "../../../stores/message-v2/bus" import { loadMessages } from "../../../stores/session-api" -import { loading, messagesLoaded } from "../../../stores/session-state" +import { getSessionMessagesLoadError, messagesLoaded, sessions } from "../../../stores/session-state" +import { setSessionTranscriptVisible } from "../../../stores/session-transcript-memory" +import { waitForInstanceWorkspaceMetadataHydration } from "../../../stores/instances" +import { useActiveSessionMessageLoad } from "../../../lib/hooks/use-active-session-message-load" import { getTaskToolSearchText } from "../search-text" +import { copyToClipboard } from "../../../lib/clipboard" +import LoadErrorState from "../../load-error-state" +import { getLegacyTaskSummary, getTaskOutputCopyText, getTruncatedTaskStepTitleCopyText, isTaskScanTruncated, isTaskStepListTruncated, resolveTaskStepTruncation, stringifyChildTaskSteps, stringifyLegacyTaskSummary, TASK_STEP_RENDER_LIMIT } from "./task-summary" + +const TASK_MESSAGE_SCAN_LIMIT = 10_000 interface TaskSummaryItem { id: string @@ -17,6 +26,8 @@ interface TaskSummaryItem { title?: string } +type TaskScanBudget = { remaining: number } + function extractSessionIdFromTaskState(state?: ToolState): string { if (!state) return "" const metadata = (state as unknown as { metadata?: Record }).metadata ?? {} @@ -163,6 +174,10 @@ export const taskRenderer: ToolRenderer = { tools: ["task"], getSearchText: getTaskToolSearchText, getAction: ({ t }) => t("toolCall.task.action.delegating"), + getOutputChrome({ toolState }) { + const output = getTaskOutputCopyText(toolState()) + return output ? { getCopyText: () => output, hasCopyText: true } : undefined + }, getTitle({ toolState }) { const state = toolState() if (!state) return undefined @@ -171,7 +186,6 @@ export const taskRenderer: ToolRenderer = { }, renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t, onContentRendered }) { const store = messageStoreBus.getOrCreate(instanceId) - const [requestedChildLoad, setRequestedChildLoad] = createSignal(false) const childSessionId = createMemo(() => { const state = toolState() @@ -185,24 +199,40 @@ export const taskRenderer: ToolRenderer = { return loadedForInstance?.has(id) ?? false }) - const childSessionLoading = createMemo(() => { + const childSessionLoadError = createMemo(() => { const id = childSessionId() - if (!id) return false - const loadingSet = loading().loadingMessages.get(instanceId) - return loadingSet?.has(id) ?? false + return id && !childSessionLoaded() ? getSessionMessagesLoadError(instanceId, id) : undefined + }) + + function retryChildSessionLoad() { + const id = childSessionId() + if (!id) return + void loadMessages(instanceId, id, { force: true }).catch(() => {}) + } + + useActiveSessionMessageLoad({ + isActive: () => Boolean(childSessionId()), + instanceId: () => instanceId, + session: () => { + const id = childSessionId() + return id ? sessions().get(instanceId)?.get(id) : undefined + }, + shouldLoad: () => !childSessionLoaded(), + loadMessages: (childInstanceId, id, options) => loadMessages(childInstanceId, id, { + registerInvalidation: options?.registerInvalidation, + }), + waitForHydration: waitForInstanceWorkspaceMetadataHydration, }) createEffect(() => { const id = childSessionId() if (!id) return - if (requestedChildLoad()) return - if (childSessionLoaded()) return - if (childSessionLoading()) return - setRequestedChildLoad(true) - void loadMessages(instanceId, id) + setSessionTranscriptVisible(instanceId, id, true) + onCleanup(() => setSessionTranscriptVisible(instanceId, id, false)) }) const [childToolKeys, setChildToolKeys] = createSignal([]) + const [childToolsTruncated, setChildToolsTruncated] = createSignal(false) let indexedSessionId = "" let indexedMessageCount = 0 @@ -215,21 +245,32 @@ export const taskRenderer: ToolRenderer = { indexedMessageTail = "" indexedPartCounts.clear() setChildToolKeys([]) + setChildToolsTruncated(false) } - function scanMessageToolParts(messageId: string, startIndex: number) { + function scanMessageToolParts(messageId: string, startIndex: number, limit: number, budget: TaskScanBudget) { + if (budget.remaining <= 0) { + setChildToolsTruncated(true) + return [] as string[] + } + budget.remaining -= 1 const record = store.getMessage(messageId) if (!record) return [] as string[] const partIds = record.partIds const keys: string[] = [] - for (let idx = startIndex; idx < partIds.length; idx += 1) { + const oldestScannedIndex = Math.max(startIndex, partIds.length - budget.remaining) + if (oldestScannedIndex > startIndex) setChildToolsTruncated(true) + let idx = partIds.length - 1 + for (; idx >= oldestScannedIndex && keys.length < limit && budget.remaining > 0; idx -= 1) { + budget.remaining -= 1 const partId = partIds[idx] const entry = record.parts?.[partId] const data = entry?.data if (!data || (data as any).type !== "tool") continue - keys.push(`${messageId}::${partId}`) + keys.unshift(`${messageId}::${partId}`) } + if (idx >= oldestScannedIndex) setChildToolsTruncated(true) indexedPartCounts.set(messageId, partIds.length) return keys } @@ -239,12 +280,18 @@ export const taskRenderer: ToolRenderer = { indexedMessageCount = messageIds.length indexedMessageTail = messageIds[messageIds.length - 1] ?? "" indexedPartCounts.clear() + setChildToolsTruncated(false) const nextKeys: string[] = [] - for (const messageId of messageIds) { - nextKeys.push(...scanMessageToolParts(messageId, 0)) + const scanLimit = TASK_STEP_RENDER_LIMIT + 1 + const budget = { remaining: TASK_MESSAGE_SCAN_LIMIT } + const oldestScannedIndex = Math.max(0, messageIds.length - TASK_MESSAGE_SCAN_LIMIT) + for (let index = messageIds.length - 1; index >= oldestScannedIndex && nextKeys.length < scanLimit && budget.remaining > 0; index -= 1) { + const keys = scanMessageToolParts(messageIds[index], 0, scanLimit - nextKeys.length, budget) + for (let keyIndex = keys.length - 1; keyIndex >= 0; keyIndex -= 1) nextKeys.unshift(keys[keyIndex]) } - setChildToolKeys(nextKeys) + setChildToolsTruncated((truncated) => isTaskScanTruncated(truncated, oldestScannedIndex > 0, isTaskStepListTruncated(nextKeys.length))) + setChildToolKeys(nextKeys.slice(-TASK_STEP_RENDER_LIMIT)) } createEffect(() => { @@ -284,33 +331,47 @@ export const taskRenderer: ToolRenderer = { } const appendedKeys: string[] = [] + const budget = { remaining: TASK_MESSAGE_SCAN_LIMIT } // Scan any new messages appended since last index. - for (let idx = indexedMessageCount; idx < messageIds.length; idx += 1) { + const appendedStart = Math.max(indexedMessageCount, messageIds.length - TASK_MESSAGE_SCAN_LIMIT) + if (appendedStart > indexedMessageCount) setChildToolsTruncated(true) + for (let idx = appendedStart; idx < messageIds.length && budget.remaining > 0; idx += 1) { const messageId = messageIds[idx] - appendedKeys.push(...scanMessageToolParts(messageId, 0)) + appendedKeys.push(...scanMessageToolParts(messageId, 0, TASK_STEP_RENDER_LIMIT, budget)) + if (appendedKeys.length > TASK_STEP_RENDER_LIMIT) { + setChildToolsTruncated(true) + appendedKeys.splice(0, appendedKeys.length - TASK_STEP_RENDER_LIMIT) + } } - // Scan a small window of recent messages for newly appended parts. - // Deltas typically affect the most recent tool call, so this avoids - // iterating every message on every revision. + // Scan the bounded indexed window so out-of-order updates are not missed. const existingCount = Math.min(indexedMessageCount, messageIds.length) - const windowStart = Math.max(0, existingCount - 3) - for (let idx = windowStart; idx < existingCount; idx += 1) { + const windowStart = Math.max(0, existingCount - TASK_MESSAGE_SCAN_LIMIT) + for (let idx = windowStart; idx < existingCount && budget.remaining > 0; idx += 1) { const messageId = messageIds[idx] const previousPartCount = indexedPartCounts.get(messageId) ?? 0 const record = store.getMessage(messageId) const nextPartCount = record?.partIds.length ?? 0 if (nextPartCount > previousPartCount) { - appendedKeys.push(...scanMessageToolParts(messageId, previousPartCount)) + appendedKeys.push(...scanMessageToolParts(messageId, previousPartCount, TASK_STEP_RENDER_LIMIT, budget)) + if (appendedKeys.length > TASK_STEP_RENDER_LIMIT) { + setChildToolsTruncated(true) + appendedKeys.splice(0, appendedKeys.length - TASK_STEP_RENDER_LIMIT) + } } } indexedMessageCount = messageIds.length indexedMessageTail = messageIds[messageIds.length - 1] ?? "" + if (indexedPartCounts.size > TASK_MESSAGE_SCAN_LIMIT) { + const retainedIds = new Set(messageIds.slice(-TASK_MESSAGE_SCAN_LIMIT)) + for (const messageId of indexedPartCounts.keys()) if (!retainedIds.has(messageId)) indexedPartCounts.delete(messageId) + } if (appendedKeys.length > 0) { - setChildToolKeys((prev) => [...prev, ...appendedKeys]) + if (childToolKeys().length + appendedKeys.length > TASK_STEP_RENDER_LIMIT) setChildToolsTruncated(true) + setChildToolKeys((prev) => [...prev, ...appendedKeys].slice(-TASK_STEP_RENDER_LIMIT)) } }) }) @@ -319,21 +380,21 @@ export const taskRenderer: ToolRenderer = { if (!state) return null const { input } = readToolStatePayload(state) const prompt = typeof input.prompt === "string" ? input.prompt : null - return ensureMarkdownContent(prompt, undefined, false) + return ensureMarkdownContent(prompt ? limitToolOutputForRender(prompt) : prompt, undefined, false) }) const outputContent = createMemo(() => { const state = toolState() if (!state) return null const output = typeof (state as { output?: unknown }).output === "string" ? ((state as { output?: string }).output as string) : null - return ensureMarkdownContent(output, undefined, false) + return ensureMarkdownContent(output ? limitToolOutputForRender(output) : output, undefined, false) }) const agentLabel = createMemo(() => { const state = toolState() if (!state) return null const { input } = readToolStatePayload(state) - return typeof input.subagent_type === "string" ? input.subagent_type : null + return typeof input.subagent_type === "string" ? limitToolTitleForRender(input.subagent_type) : null }) const modelLabel = createMemo(() => { @@ -342,8 +403,8 @@ export const taskRenderer: ToolRenderer = { const { metadata } = readToolStatePayload(state) const model = (metadata as any).model if (!model || typeof model !== "object") return null - const providerId = typeof model.providerID === "string" ? model.providerID : null - const modelId = typeof model.modelID === "string" ? model.modelID : null + const providerId = typeof model.providerID === "string" ? limitToolTitleForRender(model.providerID) : null + const modelId = typeof model.modelID === "string" ? limitToolTitleForRender(model.modelID) : null if (!providerId && !modelId) return null if (providerId && modelId) return `${providerId}/${modelId}` return providerId ?? modelId @@ -352,27 +413,26 @@ export const taskRenderer: ToolRenderer = { const headerMeta = createMemo(() => { const agent = agentLabel() const model = modelLabel() - if (agent && model) return t("toolCall.task.meta.agentModel", { agent, model }) - if (agent) return t("toolCall.task.meta.agent", { agent }) - if (model) return t("toolCall.task.meta.model", { model }) + if (agent && model) return limitToolTitleForRender(t("toolCall.task.meta.agentModel", { agent, model })) + if (agent) return limitToolTitleForRender(t("toolCall.task.meta.agent", { agent })) + if (model) return limitToolTitleForRender(t("toolCall.task.meta.model", { model })) return null }) - const legacyItems = createMemo(() => { + const legacySummary = createMemo(() => { // Track the reactive change points so we only recompute when the part/message changes messageVersion?.() partVersion?.() const state = toolState() - if (!state) return [] - - // Prefer deriving steps from the child session when loaded. - if (childSessionLoaded()) return [] - + if (!state) return getLegacyTaskSummary(undefined) const { metadata } = readToolStatePayload(state) - const summary = Array.isArray((metadata as any).summary) ? ((metadata as any).summary as any[]) : [] + return getLegacyTaskSummary((metadata as any).summary) + }) - return summary.map((entry, index) => { + const legacyItems = createMemo(() => { + if (childToolKeys().length > 0) return [] + return legacySummary().renderedEntries.map((entry, index) => { const tool = typeof entry?.tool === "string" ? (entry.tool as string) : "unknown" const stateValue = typeof entry?.state === "object" ? (entry.state as ToolState) : undefined const metadataFromEntry = typeof entry?.metadata === "object" && entry.metadata ? entry.metadata : {} @@ -383,6 +443,8 @@ export const taskRenderer: ToolRenderer = { return { id, tool, input: fallbackInput, metadata: metadataFromEntry, state: stateValue, status: statusValue, title } }) }) + const childSourceActive = () => childToolKeys().length > 0 || childToolsTruncated() + const stepsTruncated = () => resolveTaskStepTruncation(childSourceActive(), childToolsTruncated(), legacySummary().truncated) createEffect(() => { const childCount = childToolKeys().length @@ -415,15 +477,42 @@ export const taskRenderer: ToolRenderer = { - 0 || legacyItems().length > 0}> + + {(error) => ( + + )} + + + 0 || legacyItems().length > 0 || stepsTruncated()}>
{t("toolCall.task.sections.steps")} -
+ +
{t("toolCall.task.steps.truncated", { count: TASK_STEP_RENDER_LIMIT })}
+
0} fallback={ @@ -438,8 +527,10 @@ export const taskRenderer: ToolRenderer = { {(item) => { const icon = getToolIcon(item.tool) - const description = describeToolTitle(item) - const toolLabel = getToolName(item.tool) + const fullDescription = describeToolTitle(item) + const description = limitToolTitleForRender(fullDescription) + const copyTitle = getTruncatedTaskStepTitleCopyText(fullDescription) + const toolLabel = limitToolTitleForRender(getToolName(item.tool)) const status = normalizeStatus(item.status ?? item.state?.status) const statusIcon = summarizeStatusIcon(status) const statusKey = summarizeStatusLabel(status) @@ -453,6 +544,13 @@ export const taskRenderer: ToolRenderer = { {toolLabel} {description} + + {(title) => ( + + )} + {statusIcon} diff --git a/packages/ui/src/components/tool-call/renderers/todo-data.ts b/packages/ui/src/components/tool-call/renderers/todo-data.ts new file mode 100644 index 000000000..c8ebc3ebd --- /dev/null +++ b/packages/ui/src/components/tool-call/renderers/todo-data.ts @@ -0,0 +1,94 @@ +import type { ToolState } from "../../../types/tool-state" +import { readToolStatePayload, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT } from "../utils" + +export type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled" + +export interface TodoViewItem { + id: string + content: string + status: TodoViewStatus +} + +type TodoViewItems = TodoViewItem[] & { partial?: boolean } +export const TODO_ITEM_RENDER_LIMIT = 200 + +function normalizeTodoStatus(rawStatus: unknown): TodoViewStatus { + if (rawStatus === "completed" || rawStatus === "in_progress" || rawStatus === "cancelled") return rawStatus + return "pending" +} + +export function extractTodosFromState(state?: ToolState): TodoViewItems { + if (!state) return [] + const { metadata } = readToolStatePayload(state) + const todos: any[] = Array.isArray((metadata as any).todos) ? (metadata as any).todos : [] + const normalized: TodoViewItems = [] + let characters = 0 + let scannedCharacters = 0 + let index = 0 + for (; index < todos.length && index < 10_000 && normalized.length < TODO_ITEM_RENDER_LIMIT && scannedCharacters < TOOL_OUTPUT_RENDER_CHARACTER_LIMIT; index += 1) { + const todo = todos[index] + const rawContent = typeof todo?.content === "string" ? todo.content : "" + const contentPrefix = rawContent.slice(0, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT - scannedCharacters) + scannedCharacters += contentPrefix.length + const contentStart = contentPrefix.search(/\S/) + if (contentStart < 0) { + if (contentPrefix.length < rawContent.length) break + continue + } + const remaining = TOOL_OUTPUT_RENDER_CHARACTER_LIMIT - characters + const contentSlice = contentPrefix.slice(contentStart, contentStart + remaining) + const content = contentSlice.trimEnd() + if (!content) continue + const status = normalizeTodoStatus(todo.status) + const id = typeof todo?.id === "string" && todo.id.length > 0 ? todo.id : String(index) + normalized.push({ id, content, status }) + characters += content.length + if (contentStart + contentSlice.length < rawContent.length) break + } + if (index < todos.length) normalized.partial = true + return normalized +} + +export function getRenderedTodos(todos: TodoViewItem[]) { + const items: TodoViewItem[] = [] + let characters = 0 + for (let index = 0; index < todos.length && index < TODO_ITEM_RENDER_LIMIT; index += 1) { + if (characters >= TOOL_OUTPUT_RENDER_CHARACTER_LIMIT) break + const todo = todos[index] + const content = todo.content.slice(0, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT - characters) + items.push({ ...todo, content }) + characters += content.length + } + return { items, truncated: Boolean((todos as TodoViewItems).partial) || items.length < todos.length } +} + +export function hasTodoCopyText(state?: ToolState): boolean { + if (!state) return false + const { metadata } = readToolStatePayload(state) + return Array.isArray((metadata as any).todos) && (metadata as any).todos.length > 0 +} + +export function getTodoCopyText(state?: ToolState): string { + if (!state) return "[]" + const { metadata } = readToolStatePayload(state) + return JSON.stringify(Array.isArray((metadata as any).todos) ? (metadata as any).todos : [], null, 2) +} + +export function getTodoTitleKind(state?: ToolState): "plan" | "creating" | "completing" | "updating" { + if (state?.status !== "completed") return "plan" + const { metadata } = readToolStatePayload(state) + const todos: any[] = Array.isArray((metadata as any).todos) ? (metadata as any).todos : [] + if (todos.length === 0) return "plan" + let allPending = true + let allCompleted = true + for (let index = 0; index < todos.length && index < 10_000; index += 1) { + const status = normalizeTodoStatus(todos[index]?.status) + allPending = allPending && status === "pending" + allCompleted = allCompleted && status === "completed" + if (!allPending && !allCompleted) return "updating" + } + if (todos.length > 10_000) return "updating" + if (allPending) return "creating" + if (allCompleted) return "completing" + return "updating" +} diff --git a/packages/ui/src/components/tool-call/renderers/todo.tsx b/packages/ui/src/components/tool-call/renderers/todo.tsx index 6dc820d99..cddc7d83a 100644 --- a/packages/ui/src/components/tool-call/renderers/todo.tsx +++ b/packages/ui/src/components/tool-call/renderers/todo.tsx @@ -1,41 +1,11 @@ import { For, Show } from "solid-js" import type { ToolState } from "../../../types/tool-state" -import { CheckCircle, CircleEllipsis, MinusCircle, PauseCircle } from "lucide-solid" +import { CheckCircle, CircleEllipsis, Copy, MinusCircle, PauseCircle } from "lucide-solid" import type { ToolRenderer } from "../types" -import { readToolStatePayload } from "../utils" import { useI18n, tGlobal } from "../../../lib/i18n" import { getTodoToolSearchText } from "../search-text" - -export type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled" - -export interface TodoViewItem { - id: string - content: string - status: TodoViewStatus -} - -function normalizeTodoStatus(rawStatus: unknown): TodoViewStatus { - if (rawStatus === "completed" || rawStatus === "in_progress" || rawStatus === "cancelled") return rawStatus - return "pending" -} - -function extractTodosFromState(state?: ToolState): TodoViewItem[] { - if (!state) return [] - const { metadata } = readToolStatePayload(state) - const todos = Array.isArray((metadata as any).todos) ? (metadata as any).todos : [] - const items: TodoViewItem[] = [] - - for (let index = 0; index < todos.length; index++) { - const todo = todos[index] - const content = typeof todo?.content === "string" ? todo.content.trim() : "" - if (!content) continue - const status = normalizeTodoStatus((todo as any).status) - const id = typeof todo?.id === "string" && todo.id.length > 0 ? todo.id : `${index}-${content}` - items.push({ id, content, status }) - } - - return items -} +import { extractTodosFromState, getRenderedTodos, getTodoCopyText, getTodoTitleKind, hasTodoCopyText, type TodoViewItem, type TodoViewStatus } from "./todo-data" +import { copyToClipboard } from "../../../lib/clipboard" function summarizeTodos(todos: TodoViewItem[]) { return todos.reduce( @@ -82,17 +52,18 @@ interface TodoListViewProps { export function TodoListView(props: TodoListViewProps) { const { t } = useI18n() - const todos = extractTodosFromState(props.state) - const counts = summarizeTodos(todos) + const allTodos = extractTodosFromState(props.state) + const todos = getRenderedTodos(allTodos) + const counts = summarizeTodos(allTodos) - if (counts.total === 0) { + if (counts.total === 0 && !todos.truncated) { return
{props.emptyLabel ?? t("toolCall.renderer.todo.empty")}
} return (
- + {(todo) => { const label = getTodoStatusLabel(t, todo.status) return ( @@ -120,20 +91,20 @@ export function TodoListView(props: TodoListViewProps) { }}
+ +
+ {t("toolCall.output.truncated")} + +
+
) } export function getTodoTitle(state?: ToolState): string { - if (!state) return tGlobal("toolCall.renderer.todo.title.plan") - - const todos = extractTodosFromState(state) - if (state.status !== "completed" || todos.length === 0) return tGlobal("toolCall.renderer.todo.title.plan") - - const counts = summarizeTodos(todos) - if (counts.pending === counts.total) return tGlobal("toolCall.renderer.todo.title.creating") - if (counts.completed === counts.total) return tGlobal("toolCall.renderer.todo.title.completing") - return tGlobal("toolCall.renderer.todo.title.updating") + return tGlobal(`toolCall.renderer.todo.title.${getTodoTitleKind(state)}`) } export const todoRenderer: ToolRenderer = { @@ -143,6 +114,10 @@ export const todoRenderer: ToolRenderer = { getTitle({ toolState }) { return getTodoTitle(toolState()) }, + getOutputChrome({ toolState }) { + const state = toolState() + return hasTodoCopyText(state) ? { getCopyText: () => getTodoCopyText(state), hasCopyText: true } : undefined + }, renderBody({ toolState }) { const state = toolState() if (!state) return null diff --git a/packages/ui/src/components/tool-call/renderers/webfetch.tsx b/packages/ui/src/components/tool-call/renderers/webfetch.tsx index 59aa5645a..a0f7b0536 100644 --- a/packages/ui/src/components/tool-call/renderers/webfetch.tsx +++ b/packages/ui/src/components/tool-call/renderers/webfetch.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, formatUnknown, getToolName, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, formatUnknownForCopy, formatUnknownForRender, getToolName, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" import { getWebfetchToolSearchText } from "../search-text" @@ -21,16 +21,14 @@ export const webfetchRenderer: ToolRenderer = { if (!state || state.status === "pending") return undefined const { metadata } = readToolStatePayload(state) - const result = formatUnknown( - state.status === "completed" - ? state.output - : metadata.output, - ) - if (!result) return undefined + const output = state.status === "completed" ? state.output : metadata.output + if (output === undefined || output === null || output === "" || (Array.isArray(output) && output.length === 0)) return undefined + const result = formatUnknownForRender(output) return { - language: result.language ?? "text", - copyText: result.text, + language: result?.language ?? "text", + getCopyText: () => formatUnknownForCopy(output)?.text ?? null, + hasCopyText: true, wrapToggle: true, suppressInnerHeader: true, } @@ -40,7 +38,7 @@ export const webfetchRenderer: ToolRenderer = { if (!state || state.status === "pending") return null const { metadata } = readToolStatePayload(state) - const result = formatUnknown( + const result = formatUnknownForRender( state.status === "completed" ? state.output : metadata.output, diff --git a/packages/ui/src/components/tool-call/renderers/write.tsx b/packages/ui/src/components/tool-call/renderers/write.tsx index 9a7f3d540..486c69e73 100644 --- a/packages/ui/src/components/tool-call/renderers/write.tsx +++ b/packages/ui/src/components/tool-call/renderers/write.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, limitToolOutputForRender, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" import { getWriteToolSearchText } from "../search-text" @@ -24,7 +24,7 @@ export const writeRenderer: ToolRenderer = { const filePath = typeof input.filePath === "string" ? input.filePath : undefined return { language: inferLanguageFromPath(filePath) ?? "text", - copyText: contentValue, + getCopyText: () => contentValue, wrapToggle: true, suppressInnerHeader: true, } @@ -35,7 +35,7 @@ export const writeRenderer: ToolRenderer = { const { metadata, input } = readToolStatePayload(state) const contentValue = typeof input.content === "string" ? input.content : metadata.content const filePath = typeof input.filePath === "string" ? input.filePath : undefined - const content = ensureMarkdownContent(contentValue ?? null, inferLanguageFromPath(filePath), true) + const content = ensureMarkdownContent(typeof contentValue === "string" ? limitToolOutputForRender(contentValue) : null, inferLanguageFromPath(filePath), true) if (!content) return null return renderMarkdown({ content, size: "large", disableHighlight: state.status === "running" }) }, diff --git a/packages/ui/src/components/tool-call/types.ts b/packages/ui/src/components/tool-call/types.ts index d9f4baebe..0997d4dcd 100644 --- a/packages/ui/src/components/tool-call/types.ts +++ b/packages/ui/src/components/tool-call/types.ts @@ -6,6 +6,7 @@ export type ToolCallPart = Extract export interface DiffPayload { diffText: string + copyText?: string filePath?: string } @@ -37,6 +38,7 @@ export interface DiffRenderOptions { variant?: string disableScrollTracking?: boolean label?: string + onFullDiffAccess?: (diffText: string) => void /** * Optional cache key suffix to avoid collisions when rendering multiple diffs * within the same tool call (e.g. apply_patch). @@ -103,6 +105,8 @@ export interface ToolOutputChrome { title?: string language?: string copyText?: string | null + getCopyText?: () => string | null + hasCopyText?: boolean actions?: JSXElement wrapToggle?: boolean suppressInnerHeader?: boolean diff --git a/packages/ui/src/components/tool-call/utils.ts b/packages/ui/src/components/tool-call/utils.ts index 29ca0dc3d..2862fd3f4 100644 --- a/packages/ui/src/components/tool-call/utils.ts +++ b/packages/ui/src/components/tool-call/utils.ts @@ -4,12 +4,40 @@ import type { ToolState, ToolStateCompleted, ToolStateError, ToolStateRunning } import type { DiffPayload } from "./types" import { getLogger } from "../../lib/logger" import { tGlobal } from "../../lib/i18n" +import { exceedsRetainedByteLimit } from "../../lib/retained-size" const log = getLogger("session") export type { ToolStateCompleted, ToolStateError, ToolStateRunning } export const diffCapableTools = new Set(["edit", "patch"]) +export const TOOL_OUTPUT_RENDER_CHARACTER_LIMIT = 10_000 +export const TOOL_TITLE_RENDER_CHARACTER_LIMIT = 384 +export const MESSAGE_PART_RENDER_LIMIT = 200 + +export function getItemsForRender(items: readonly T[], limit: number) { + return { parts: items.slice(0, limit), truncated: items.length > limit } +} + +export function limitToolOutputForRender(text: string): string { + if (text.length <= TOOL_OUTPUT_RENDER_CHARACTER_LIMIT) return text + const suffix = `\n\n${tGlobal("toolCall.output.truncated")}` + return `${text.slice(0, Math.max(0, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT - suffix.length))}${suffix}` +} + +export function shouldRenderDiffAsPlainText(text: string): boolean { + return text.length > TOOL_OUTPUT_RENDER_CHARACTER_LIMIT +} + +export function shouldRenderDiffPayloadAsPlainText(payload: DiffPayload): boolean { + return shouldRenderDiffAsPlainText(payload.diffText) + || (payload.copyText?.length ?? 0) > TOOL_OUTPUT_RENDER_CHARACTER_LIMIT +} + +export function limitToolTitleForRender(text: string): string { + if (text.length <= TOOL_TITLE_RENDER_CHARACTER_LIMIT) return text + return `${text.slice(0, TOOL_TITLE_RENDER_CHARACTER_LIMIT - 3)}...` +} export function isToolStateRunning(state: ToolState): state is ToolStateRunning { return state.status === "running" @@ -148,6 +176,41 @@ export function formatUnknown(value: unknown): { text: string; language?: string return null } +export function formatUnknownForRender(value: unknown): { text: string; language?: string } | null { + if (typeof value !== "string" && exceedsRetainedByteLimit(value, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT)) { + return { text: tGlobal("toolCall.output.tooLarge") } + } + const result = formatUnknown(value) + return result ? { ...result, text: limitToolOutputForRender(result.text) } : null +} + +export function formatToolInputForCopy(input: unknown): { text: string; language?: string } | null { + try { + const text = JSON.stringify(input, null, 2) + return typeof text === "string" ? { text, language: "json" } : null + } catch (error) { + log.error("Failed to stringify tool call input", error) + return null + } +} + +export function formatToolInputForRender(input: unknown): { text: string; language?: string } | null { + if (typeof input !== "string" && exceedsRetainedByteLimit(input, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT)) { + return { text: JSON.stringify(tGlobal("toolCall.output.tooLarge")), language: "json" } + } + const formatted = formatToolInputForCopy(input) + return formatted ? { ...formatted, text: limitToolOutputForRender(formatted.text) } : null +} + +export function formatUnknownForCopy(value: unknown): { text: string; language?: string } | null { + try { + return formatUnknown(value) + } catch (error) { + log.error("Failed to format tool call output for copy", error) + return { text: tGlobal("toolCall.output.tooLarge") } + } +} + export function inferLanguageFromPath(path?: string): string | undefined { return getLanguageFromPath(path || "") } @@ -161,7 +224,11 @@ export function extractDiffPayload(toolName: string, state?: ToolState): DiffPay let diffText: string | null = null for (const candidate of candidates) { - if (typeof candidate === "string" && isRenderableDiffText(candidate)) { + if (typeof candidate !== "string") continue + const renderable = candidate.length > TOOL_OUTPUT_RENDER_CHARACTER_LIMIT + ? /(^|\n)@@/.test(candidate.slice(0, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT)) + : isRenderableDiffText(candidate) + if (renderable) { diffText = candidate break } @@ -234,13 +301,14 @@ export function buildToolSpeechText(options: { }): string { const sections: string[] = [] - if (options.title.trim()) { - sections.push(options.title.trim()) + const title = limitToolOutputForRender(options.title).trim() + if (title) { + sections.push(title) } const { input, output } = readToolStatePayload(options.state) - const formattedInput = formatUnknown(input) - const formattedOutput = formatUnknown(output) + const formattedInput = formatUnknownForRender(input) + const formattedOutput = formatUnknownForRender(output) if (formattedInput?.text?.trim()) { sections.push(`${options.t("toolCall.io.input")}:\n${formattedInput.text.trim()}`) @@ -250,13 +318,14 @@ export function buildToolSpeechText(options: { sections.push(`${options.t("toolCall.io.output")}:\n${formattedOutput.text.trim()}`) } - if (options.state?.status === "error" && options.state.error?.trim()) { - sections.push(`${options.t("toolCall.error.label")} ${options.state.error.trim()}`) + const error = options.state?.status === "error" ? limitToolOutputForRender(options.state.error ?? "").trim() : "" + if (error) { + sections.push(`${options.t("toolCall.error.label")} ${error}`) } if (sections.length === 1 && options.state?.status === "pending") { sections.push(options.t("toolCall.pending.waitingToRun")) } - return sections.join("\n\n").trim() + return limitToolOutputForRender(sections.join("\n\n").trim()) } diff --git a/packages/ui/src/lib/global-cache.test.ts b/packages/ui/src/lib/global-cache.test.ts new file mode 100644 index 000000000..70c2b5e1d --- /dev/null +++ b/packages/ui/src/lib/global-cache.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { captureCacheAuthority, clearCacheForInstance, clearCacheForSession, getCacheEntry, onCacheSessionChanged, setCacheEntry } from "./global-cache.ts" + +test("rejects stale writes and evicts the globally oldest cache entries", () => { + const entry = { instanceId: "cache", sessionId: "stale", scope: "render", cacheId: "value", version: "1" } + const authority = captureCacheAuthority(entry) + const changed: string[] = [] + const stop = onCacheSessionChanged((instanceId, sessionId) => changed.push(`${instanceId}/${sessionId}`)) + try { + clearCacheForSession(entry.instanceId, entry.sessionId) + setCacheEntry(entry, "stale", authority) + assert.equal(getCacheEntry(entry), undefined) + + for (let index = 0; index < 11; index += 1) { + setCacheEntry({ ...entry, sessionId: `session-${index}` }, "x".repeat(1_500_000)) + } + changed.length = 0 + setCacheEntry({ ...entry, sessionId: "trigger" }, "x".repeat(1_500_000)) + assert.ok(changed.includes("cache/session-0")) + assert.equal(getCacheEntry({ ...entry, sessionId: "session-0" }), undefined) + assert.notEqual(getCacheEntry({ ...entry, sessionId: "session-10" }), undefined) + } finally { + stop() + clearCacheForInstance(entry.instanceId) + } +}) + +test("does not double-subtract an entry while replacing it", () => { + const entry = { instanceId: "replace", scope: "render", cacheId: "value", version: "1" } + try { + for (let index = 0; index < 15; index += 1) { + setCacheEntry({ ...entry, sessionId: `session-${index}` }, "x".repeat(1_060_000)) + } + setCacheEntry({ ...entry, sessionId: "session-0" }, "x".repeat(2_000_000)) + assert.equal(getCacheEntry({ ...entry, sessionId: "session-1" }), undefined) + } finally { + clearCacheForInstance(entry.instanceId) + } +}) diff --git a/packages/ui/src/lib/global-cache.ts b/packages/ui/src/lib/global-cache.ts index 1a5cb2575..fbdaf0627 100644 --- a/packages/ui/src/lib/global-cache.ts +++ b/packages/ui/src/lib/global-cache.ts @@ -1,3 +1,5 @@ +import { estimateRetainedBytes } from "./retained-size" + export interface CacheEntryBaseParams { instanceId?: string sessionId?: string @@ -9,9 +11,22 @@ export interface CacheEntryParams extends CacheEntryBaseParams { version: string } +export interface CacheAuthority { + instanceKey: string + sessionKey: string + scope: string + cacheId: string + version: string + generation: number + writeToken: number +} + type VersionedCacheEntry = { version: string value: unknown + byteSize: number + keyBytes: number + accessedAt: number } type CacheValueMap = Map @@ -19,12 +34,105 @@ type CacheScopeMap = Map type CacheSessionMap = Map const GLOBAL_KEY = "GLOBAL" +const MAX_SCOPE_CACHE_ENTRIES = 64 +const MAX_CACHE_ENTRY_BYTES = 4 * 1024 * 1024 +const MAX_GLOBAL_CACHE_BYTES = 32 * 1024 * 1024 +const MAX_GLOBAL_CACHE_ENTRIES = 4_096 const cacheStore = new Map() +let cacheGeneration = 0 +let writeSequence = 0 +const pendingWrites = new Map() +const pendingAuthorityByParams = new WeakMap() +const cacheSessionChangeHandlers = new Set<(instanceId: string, sessionId: string) => void>() +let retainedBytes = 0 +let retainedEntries = 0 +let accessSequence = 0 + +function recalculateRetainedSize(): void { + retainedBytes = 0 + retainedEntries = 0 + for (const sessionMap of cacheStore.values()) { + for (const scopeMap of sessionMap.values()) { + for (const valueMap of scopeMap.values()) { + for (const entry of valueMap.values()) { + retainedBytes += entry.byteSize + retainedEntries += 1 + } + } + } + } +} function resolveKey(value?: string) { return value && value.length > 0 ? value : GLOBAL_KEY } +function writeKey(instanceKey: string, sessionKey: string, scope: string, cacheId: string): string { + return `${instanceKey}\u0000${sessionKey}\u0000${scope}\u0000${cacheId}` +} + +function invalidateAllPendingWrites(): void { + cacheGeneration += 1 + pendingWrites.clear() +} + +function invalidatePendingWrite(instanceKey: string, sessionKey: string, scope: string, cacheId: string): void { + pendingWrites.delete(writeKey(instanceKey, sessionKey, scope, cacheId)) +} + +function notifyCacheSessionChanged(params: CacheEntryBaseParams): void { + if (!params.instanceId || !params.sessionId) return + for (const handler of cacheSessionChangeHandlers) handler(params.instanceId, params.sessionId) +} + +export function onCacheSessionChanged(handler: (instanceId: string, sessionId: string) => void): () => void { + cacheSessionChangeHandlers.add(handler) + return () => cacheSessionChangeHandlers.delete(handler) +} + +export function* getCacheRetainedEntriesForSession(instanceId: string, sessionId: string): Generator<{ value: unknown; keyBytes: number }> { + const scopeMap = cacheStore.get(resolveKey(instanceId))?.get(resolveKey(sessionId)) + if (!scopeMap) return + for (const valueMap of scopeMap.values()) { + for (const entry of valueMap.values()) yield { value: entry.value, keyBytes: entry.keyBytes } + } +} + +export function captureCacheAuthority(params: CacheEntryParams): CacheAuthority { + const instanceKey = resolveKey(params.instanceId) + const sessionKey = resolveKey(params.sessionId) + const scopePrefix = `${instanceKey}\u0000${sessionKey}\u0000${params.scope}\u0000` + const currentKey = writeKey(instanceKey, sessionKey, params.scope, params.cacheId) + const scopeWrites = [...pendingWrites.keys()].filter((key) => key.startsWith(scopePrefix) && key !== currentKey) + if (scopeWrites.length >= MAX_SCOPE_CACHE_ENTRIES) pendingWrites.delete(scopeWrites[0]!) + if (pendingWrites.size >= MAX_GLOBAL_CACHE_ENTRIES && !pendingWrites.has(currentKey)) invalidateAllPendingWrites() + const writeToken = ++writeSequence + pendingWrites.set(currentKey, writeToken) + const authority = { + instanceKey, + sessionKey, + scope: params.scope, + cacheId: params.cacheId, + version: params.version, + generation: cacheGeneration, + writeToken, + } + pendingAuthorityByParams.set(params, authority) + return authority +} + +function hasCacheAuthority(params: CacheEntryParams, authority: CacheAuthority): boolean { + const instanceKey = resolveKey(params.instanceId) + const sessionKey = resolveKey(params.sessionId) + return instanceKey === authority.instanceKey + && sessionKey === authority.sessionKey + && params.scope === authority.scope + && params.cacheId === authority.cacheId + && params.version === authority.version + && cacheGeneration === authority.generation + && pendingWrites.get(writeKey(instanceKey, sessionKey, params.scope, params.cacheId)) === authority.writeToken +} + function getScopeValueMap(params: CacheEntryParams, create: boolean): CacheValueMap | undefined { const instanceKey = resolveKey(params.instanceId) const sessionKey = resolveKey(params.sessionId) @@ -83,54 +191,127 @@ function cleanupHierarchy(instanceKey: string, sessionKey: string, scopeKey?: st } } -export function setCacheEntry(params: CacheEntryParams, value: T | undefined): void { +function evictOldestCacheEntry(): boolean { + let oldest: { instanceKey: string; sessionKey: string; scope: string; cacheId: string; entry: VersionedCacheEntry } | undefined + for (const [instanceKey, sessionMap] of cacheStore) { + for (const [sessionKey, scopeMap] of sessionMap) { + for (const [scope, valueMap] of scopeMap) { + for (const [cacheId, entry] of valueMap) { + if (!oldest || entry.accessedAt < oldest.entry.accessedAt) oldest = { instanceKey, sessionKey, scope, cacheId, entry } + } + } + } + } + if (!oldest) return false + cacheStore.get(oldest.instanceKey)?.get(oldest.sessionKey)?.get(oldest.scope)?.delete(oldest.cacheId) + retainedBytes -= oldest.entry.byteSize + retainedEntries -= 1 + invalidatePendingWrite(oldest.instanceKey, oldest.sessionKey, oldest.scope, oldest.cacheId) + cleanupHierarchy(oldest.instanceKey, oldest.sessionKey, oldest.scope) + if (oldest.instanceKey !== GLOBAL_KEY && oldest.sessionKey !== GLOBAL_KEY) { + notifyCacheSessionChanged({ instanceId: oldest.instanceKey, sessionId: oldest.sessionKey, scope: oldest.scope }) + } + return true +} + +export function setCacheEntry(params: CacheEntryParams, value: T | undefined, authority?: CacheAuthority): void { const instanceKey = resolveKey(params.instanceId) const sessionKey = resolveKey(params.sessionId) + const resolvedAuthority = authority ?? pendingAuthorityByParams.get(params) + pendingAuthorityByParams.delete(params) + if (resolvedAuthority && !hasCacheAuthority(params, resolvedAuthority)) return + invalidatePendingWrite(instanceKey, sessionKey, params.scope, params.cacheId) if (value === undefined) { const existingMap = getScopeValueMap(params, false) + const existing = existingMap?.get(params.cacheId) + retainedBytes -= existing?.byteSize ?? 0 + if (existing) retainedEntries -= 1 existingMap?.delete(params.cacheId) cleanupHierarchy(instanceKey, sessionKey, params.scope) + if (existing) notifyCacheSessionChanged(params) return } - const scopeEntries = getScopeValueMap(params, true) - scopeEntries?.set(params.cacheId, { version: params.version, value }) + const scopeEntries = getScopeValueMap(params, false) + const existing = scopeEntries?.get(params.cacheId) + retainedBytes -= existing?.byteSize ?? 0 + if (existing) retainedEntries -= 1 + scopeEntries?.delete(params.cacheId) + const keyBytes = [params.instanceId, params.sessionId, params.scope, params.cacheId, params.version] + .reduce((total, part) => total + (part?.length ?? 0) * 2 + 16, 0) + const byteSize = estimateRetainedBytes(value, MAX_CACHE_ENTRY_BYTES) + keyBytes + if (byteSize > MAX_CACHE_ENTRY_BYTES) { + scopeEntries?.delete(params.cacheId) + cleanupHierarchy(instanceKey, sessionKey, params.scope) + if (existing) notifyCacheSessionChanged(params) + return + } + while (retainedBytes + byteSize > MAX_GLOBAL_CACHE_BYTES || retainedEntries >= MAX_GLOBAL_CACHE_ENTRIES) { + if (!evictOldestCacheEntry()) break + } + const target = getScopeValueMap(params, true) + if (!target) return + target.delete(params.cacheId) + target.set(params.cacheId, { version: params.version, value, byteSize, keyBytes, accessedAt: ++accessSequence }) + retainedBytes += byteSize + retainedEntries += 1 + while (target.size > MAX_SCOPE_CACHE_ENTRIES) { + const oldest = target.keys().next().value + if (oldest === undefined) break + retainedBytes -= target.get(oldest)?.byteSize ?? 0 + target.delete(oldest) + retainedEntries -= 1 + invalidatePendingWrite(instanceKey, sessionKey, params.scope, oldest) + } + notifyCacheSessionChanged(params) } export function getCacheEntry(params: CacheEntryParams): T | undefined { const scopeEntries = getScopeValueMap(params, false) const entry = scopeEntries?.get(params.cacheId) if (!entry || entry.version !== params.version) { + captureCacheAuthority(params) return undefined } + invalidatePendingWrite(resolveKey(params.instanceId), resolveKey(params.sessionId), params.scope, params.cacheId) + scopeEntries!.delete(params.cacheId) + entry.accessedAt = ++accessSequence + scopeEntries!.set(params.cacheId, entry) + captureCacheAuthority(params) return entry.value as T } export function clearCacheScope(params: CacheEntryBaseParams): void { const instanceKey = resolveKey(params.instanceId) const sessionKey = resolveKey(params.sessionId) + invalidateAllPendingWrites() const sessionMap = cacheStore.get(instanceKey) if (!sessionMap) return const scopeMap = sessionMap.get(sessionKey) if (!scopeMap) return scopeMap.delete(params.scope) cleanupHierarchy(instanceKey, sessionKey) + recalculateRetainedSize() } export function clearCacheForSession(instanceId?: string, sessionId?: string): void { const instanceKey = resolveKey(instanceId) const sessionKey = resolveKey(sessionId) + invalidateAllPendingWrites() const sessionMap = cacheStore.get(instanceKey) if (!sessionMap) return sessionMap.delete(sessionKey) if (sessionMap.size === 0) { cacheStore.delete(instanceKey) } + recalculateRetainedSize() } export function clearCacheForInstance(instanceId?: string): void { const instanceKey = resolveKey(instanceId) + invalidateAllPendingWrites() cacheStore.delete(instanceKey) + recalculateRetainedSize() } diff --git a/packages/ui/src/lib/hooks/use-active-session-message-load.test.ts b/packages/ui/src/lib/hooks/use-active-session-message-load.test.ts index 08ac16a00..166cd2d66 100644 --- a/packages/ui/src/lib/hooks/use-active-session-message-load.test.ts +++ b/packages/ui/src/lib/hooks/use-active-session-message-load.test.ts @@ -116,4 +116,5 @@ describe("useActiveSessionMessageLoad", () => { dispose() } }) + }) diff --git a/packages/ui/src/lib/hooks/use-active-session-message-load.ts b/packages/ui/src/lib/hooks/use-active-session-message-load.ts index 05fc98471..75902c670 100644 --- a/packages/ui/src/lib/hooks/use-active-session-message-load.ts +++ b/packages/ui/src/lib/hooks/use-active-session-message-load.ts @@ -1,4 +1,4 @@ -import { createEffect, createMemo } from "solid-js" +import { createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js" /** * Dependencies for {@link useActiveSessionMessageLoad}. Everything is injected @@ -13,8 +13,14 @@ export interface ActiveSessionMessageLoadDeps { instanceId: () => string /** The current session object (or undefined). Read reactively. */ session: () => { id: string } | undefined + /** Optional reactive gate. Each false-to-true transition reloads the same session. */ + shouldLoad?: () => boolean /** Loads the messages for a session. */ - loadMessages: (instanceId: string, sessionId: string) => Promise | void + loadMessages: ( + instanceId: string, + sessionId: string, + options?: { registerInvalidation?: (invalidate: () => void) => void }, + ) => Promise | void /** Resolves once the instance's workspace metadata has hydrated. */ waitForHydration: (instanceId: string) => Promise /** Optional error sink for a rejected load. */ @@ -38,17 +44,44 @@ export interface ActiveSessionMessageLoadDeps { * preserving load-on-activation and load-on-switch behavior. */ export function useActiveSessionMessageLoad(deps: ActiveSessionMessageLoadDeps): void { - const activeSessionId = createMemo(() => (deps.isActive() ? (deps.session()?.id ?? null) : null)) + const activeBinding = createMemo(() => { + const sessionId = deps.isActive() ? deps.session()?.id : undefined + return sessionId ? `${deps.instanceId()}\u0000${sessionId}` : null + }) + const [reloadVersion, setReloadVersion] = createSignal(0) + let previousLoadBinding: string | null = null + createEffect(() => { + if (!deps.shouldLoad) return + const binding = activeBinding() + const loadBinding = binding && deps.shouldLoad() ? binding : null + if (loadBinding && loadBinding !== previousLoadBinding) setReloadVersion((value) => value + 1) + previousLoadBinding = loadBinding + }) + createEffect(() => { - const sessionId = activeSessionId() - if (!sessionId) return - const instanceId = deps.instanceId() + const binding = activeBinding() + if (deps.shouldLoad) { + reloadVersion() + if (!untrack(deps.shouldLoad)) return + } + if (!binding) return + const [instanceId, sessionId] = binding.split("\u0000") + let invalidate = () => {} + let cancelled = false + let pending = false + onCleanup(() => { + cancelled = true + if (pending) invalidate() + }) void Promise.resolve(deps.waitForHydration(instanceId)) .then(() => { // Re-check after the async gate: the user may have switched away or to // a different session while metadata was hydrating. - if (!deps.isActive() || deps.session()?.id !== sessionId) return - return deps.loadMessages(instanceId, sessionId) + if (cancelled || !deps.isActive() || deps.instanceId() !== instanceId || deps.session()?.id !== sessionId) return + pending = true + return Promise.resolve(deps.loadMessages(instanceId, sessionId, { + registerInvalidation: (next) => { invalidate = next }, + })).finally(() => { pending = false }) }) .catch((error) => deps.onError?.(error)) }) diff --git a/packages/ui/src/lib/hooks/use-app-session-capture.test.ts b/packages/ui/src/lib/hooks/use-app-session-capture.test.ts index 14c26d45c..53f0683b1 100644 --- a/packages/ui/src/lib/hooks/use-app-session-capture.test.ts +++ b/packages/ui/src/lib/hooks/use-app-session-capture.test.ts @@ -13,3 +13,9 @@ it("makes native shutdown terminal for reactive captures", () => { assert.match(capture, /if \(nativeShutdown\) nativeShutdownStarted = true/) assert.match(capture, /if \(!enabled\(\) \|\| disposed \|\| nativeShutdownStarted\) return/) }) + +it("keeps navigation flushes nonterminal", () => { + assert.match(capture, /flush\(nativeShutdown\)/) + assert.match(capture, /"client-state:flush-requested",[\s\S]*?, true\)/) + assert.match(capture, /"client-state:navigation-flush-requested",[\s\S]*?, false\)/) +}) diff --git a/packages/ui/src/lib/hooks/use-app-session-capture.ts b/packages/ui/src/lib/hooks/use-app-session-capture.ts index 0d8ad43be..df9bc82e5 100644 --- a/packages/ui/src/lib/hooks/use-app-session-capture.ts +++ b/packages/ui/src/lib/hooks/use-app-session-capture.ts @@ -181,8 +181,8 @@ export function useAppSessionCapture() { } const nativeUnlisteners: Array<() => void> = [] let nativeDisposed = false - const register = (event: string, acknowledge: (payload: T) => void | Promise) => listen(event, ({ payload }) => { - void flush(true).then(() => acknowledge(payload)).catch((error) => log.error(`Failed to handle ${event}`, error)) + const register = (event: string, acknowledge: (payload: T) => void | Promise, nativeShutdown: boolean) => listen(event, ({ payload }) => { + void flush(nativeShutdown).then(() => acknowledge(payload)).catch((error) => log.error(`Failed to handle ${event}`, error)) }).then((unlisten) => { if (nativeDisposed) unlisten() else nativeUnlisteners.push(unlisten) @@ -190,9 +190,9 @@ export function useAppSessionCapture() { const ready = isTauriHost() && isLocalWindow() ? Promise.all([ register<{ generation: number }>("client-state:flush-requested", - ({ generation }) => acknowledgeNativeClientStateRendererFlush(generation)), + ({ generation }) => acknowledgeNativeClientStateRendererFlush(generation), true), register<{ generation: number }>("client-state:navigation-flush-requested", - ({ generation }) => acknowledgeNativeClientStateNavigationFlush(generation)), + ({ generation }) => acknowledgeNativeClientStateNavigationFlush(generation), false), ]).then(() => undefined) : Promise.resolve() const markScrollAuthority = (instanceId: string, sessionId: string) => { diff --git a/packages/ui/src/lib/hooks/use-foreground-refresh.ts b/packages/ui/src/lib/hooks/use-foreground-refresh.ts index 53ed21b76..8c283ce16 100644 --- a/packages/ui/src/lib/hooks/use-foreground-refresh.ts +++ b/packages/ui/src/lib/hooks/use-foreground-refresh.ts @@ -41,18 +41,9 @@ export function useForegroundRefresh(options: ForegroundRefreshOptions): void { } controller.handle(status) }) - const streamGenerations = new Map() - const unsubscribeGeneration = serverEvents.on("instance.eventStatus", (event) => { - if (event.type !== "instance.eventStatus" || event.status !== "connected") return - const previous = streamGenerations.get(event.instanceId) - streamGenerations.set(event.instanceId, event.generation) - if (previous !== undefined && previous !== event.generation) controller.invalidate() - }) - onCleanup(() => { controller.dispose() unsubscribe() - unsubscribeGeneration() }) }) } diff --git a/packages/ui/src/lib/hooks/use-global-cache.ts b/packages/ui/src/lib/hooks/use-global-cache.ts index b3ba1aae4..fe9eebbf8 100644 --- a/packages/ui/src/lib/hooks/use-global-cache.ts +++ b/packages/ui/src/lib/hooks/use-global-cache.ts @@ -1,6 +1,8 @@ import { type Accessor, createMemo } from "solid-js" import { + type CacheAuthority, type CacheEntryParams, + captureCacheAuthority, getCacheEntry, setCacheEntry, clearCacheScope, @@ -14,6 +16,7 @@ import { * automatically fall back to the global buckets. */ export function useGlobalCache(params: UseGlobalCacheParams): GlobalCacheHandle { + let pendingAuthority: CacheAuthority | undefined const resolvedEntry = createMemo(() => { const instanceId = normalizeId(resolveValue(params.instanceId)) const sessionId = normalizeId(resolveValue(params.sessionId)) @@ -35,10 +38,12 @@ export function useGlobalCache(params: UseGlobalCacheParams): GlobalCacheHandle return { get() { - return getCacheEntry(resolvedEntry()) + const entry = resolvedEntry() + return getCacheEntry(entry) }, - set(value: T | undefined) { - setCacheEntry(resolvedEntry(), value) + set(value: T | undefined, authority?: CacheAuthority) { + setCacheEntry(resolvedEntry(), value, authority ?? pendingAuthority) + pendingAuthority = undefined }, clearScope() { clearCacheScope(scopeParams()) @@ -54,6 +59,9 @@ export function useGlobalCache(params: UseGlobalCacheParams): GlobalCacheHandle params() { return resolvedEntry() }, + authority() { + return pendingAuthority = captureCacheAuthority(resolvedEntry()) + }, } } @@ -80,9 +88,10 @@ interface UseGlobalCacheParams { interface GlobalCacheHandle { get(): T | undefined - set(value: T | undefined): void + set(value: T | undefined, authority?: CacheAuthority): void clearScope(): void clearSession(): void clearInstance(): void params(): CacheEntryParams + authority(): CacheAuthority } diff --git a/packages/ui/src/lib/hooks/use-message-window-paging.ts b/packages/ui/src/lib/hooks/use-message-window-paging.ts new file mode 100644 index 000000000..94921d753 --- /dev/null +++ b/packages/ui/src/lib/hooks/use-message-window-paging.ts @@ -0,0 +1,92 @@ +import { createEffect, onCleanup, type Accessor } from "solid-js" +import type { VirtualFollowListApi, VirtualFollowScrollSnapshot } from "../../components/virtual-follow-list" +import type { InstanceMessageStore } from "../../stores/message-v2/instance-store" +import { loadNewerMessages, loadOlderMessages } from "../../stores/session-api" + +export function useMessageWindowPaging(options: { + instanceId: Accessor + sessionId: Accessor + isActive: Accessor + store: Accessor + api: Accessor + element: Accessor +}): void { + let loading = false + + const load = (direction: "older" | "newer") => { + if (loading || !options.isActive()) return + const window = options.store().getMessageWindow(options.sessionId()) + if (!window || (direction === "older" ? !window.olderCursor : window.cursor === undefined)) return + + const instanceId = options.instanceId() + const sessionId = options.sessionId() + if (options.store().hasPendingSends(sessionId)) return + const api = options.api() + const followSnapshot = api?.captureScrollSnapshot() + loading = true + const request = direction === "older" ? loadOlderMessages(instanceId, sessionId) : loadNewerMessages(instanceId, sessionId) + void request.then((committed) => { + if (!committed || !options.isActive() || options.instanceId() !== instanceId || options.sessionId() !== sessionId) { + loading = false + return + } + if (!api || !followSnapshot) { + loading = false + return + } + const snapshot: VirtualFollowScrollSnapshot = direction === "older" + ? { scrollTop: Number.MAX_SAFE_INTEGER, atBottom: true, followModeType: followSnapshot.followModeType } + : { scrollTop: 0, atBottom: false, followModeType: followSnapshot.followModeType } + api.restoreScrollSnapshot(snapshot, { + behavior: "auto", + onApplied: () => { loading = false }, + onCancelled: () => { loading = false }, + fallback: () => { loading = false }, + }) + }).catch(() => { loading = false }) + } + + createEffect(() => { + const element = options.element() + void options.sessionId() + if (!element || !options.isActive()) return + loading = false + let previousTop = element.scrollTop + let lastTouchY: number | null = null + const atTop = () => element.scrollTop <= 1 + const atBottom = () => element.scrollHeight - element.scrollTop - element.clientHeight <= 1 + const handleScroll = () => { + const nextTop = element.scrollTop + if (nextTop < previousTop && atTop()) load("older") + else if (nextTop > previousTop && atBottom()) load("newer") + previousTop = nextTop + } + const handleWheel = (event: WheelEvent) => { + if (event.deltaY < 0 && atTop()) load("older") + else if (event.deltaY > 0 && atBottom()) load("newer") + } + const handleTouchStart = (event: TouchEvent) => { lastTouchY = event.touches[0]?.clientY ?? null } + const handleTouchMove = (event: TouchEvent) => { + const nextY = event.touches[0]?.clientY ?? null + if (nextY !== null && lastTouchY !== null) { + if (nextY > lastTouchY && atTop()) load("older") + else if (nextY < lastTouchY && atBottom()) load("newer") + } + lastTouchY = nextY + } + const handleTouchEnd = () => { lastTouchY = null } + element.addEventListener("scroll", handleScroll, { passive: true }) + element.addEventListener("wheel", handleWheel, { passive: true }) + element.addEventListener("touchstart", handleTouchStart, { passive: true }) + element.addEventListener("touchmove", handleTouchMove, { passive: true }) + element.addEventListener("touchend", handleTouchEnd, { passive: true }) + onCleanup(() => { + element.removeEventListener("scroll", handleScroll) + element.removeEventListener("wheel", handleWheel) + element.removeEventListener("touchstart", handleTouchStart) + element.removeEventListener("touchmove", handleTouchMove) + element.removeEventListener("touchend", handleTouchEnd) + loading = false + }) + }) +} diff --git a/packages/ui/src/lib/i18n/messages/de/toolCall.ts b/packages/ui/src/lib/i18n/messages/de/toolCall.ts index 33e42ec6f..11852db3f 100644 --- a/packages/ui/src/lib/i18n/messages/de/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/de/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "Verzeichnis wird aufgelistet...", "toolCall.renderer.bash.title.timeout": "Zeitüberschreitung: {timeout}", + "toolCall.output.truncated": "[Ausgabe für die Darstellung gekürzt; kopieren Sie sie für die vollständige Ausgabe]", + "toolCall.output.tooLarge": "Die strukturierte Ausgabe wird nicht dargestellt, da sie zu groß ist.", + "toolCall.task.steps.truncated": "Die neuesten {count} Schritte werden angezeigt; ältere Schritte wurden ausgelassen.", "toolCall.renderer.read.detail.offset": "Offset: {offset}", "toolCall.renderer.read.detail.limit": "Limit: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/en/toolCall.ts b/packages/ui/src/lib/i18n/messages/en/toolCall.ts index 3bb657c01..c929ce5ad 100644 --- a/packages/ui/src/lib/i18n/messages/en/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/en/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "Listing directory...", "toolCall.renderer.bash.title.timeout": "Timeout: {timeout}", + "toolCall.output.truncated": "[Output truncated for rendering; copy to access the full output]", + "toolCall.output.tooLarge": "Structured output omitted from rendering because it is too large.", + "toolCall.task.steps.truncated": "Showing the most recent {count} steps; older steps are omitted.", "toolCall.renderer.read.detail.offset": "Offset: {offset}", "toolCall.renderer.read.detail.limit": "Limit: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/es/toolCall.ts b/packages/ui/src/lib/i18n/messages/es/toolCall.ts index a04eb7b10..1b5959f5a 100644 --- a/packages/ui/src/lib/i18n/messages/es/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/es/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "Listando directorio...", "toolCall.renderer.bash.title.timeout": "Tiempo de espera: {timeout}", + "toolCall.output.truncated": "[Salida truncada para la visualización; cópiala para acceder a la salida completa]", + "toolCall.output.tooLarge": "La salida estructurada no se muestra porque es demasiado grande.", + "toolCall.task.steps.truncated": "Se muestran los {count} pasos más recientes; se omiten los anteriores.", "toolCall.renderer.read.detail.offset": "Desplazamiento: {offset}", "toolCall.renderer.read.detail.limit": "Límite: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/fr/toolCall.ts b/packages/ui/src/lib/i18n/messages/fr/toolCall.ts index 3e8ff28c9..42b832ebc 100644 --- a/packages/ui/src/lib/i18n/messages/fr/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/fr/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "Liste du répertoire...", "toolCall.renderer.bash.title.timeout": "Délai : {timeout}", + "toolCall.output.truncated": "[Sortie tronquée pour l’affichage ; copiez-la pour accéder à la sortie complète]", + "toolCall.output.tooLarge": "Sortie structurée omise de l’affichage car elle est trop volumineuse.", + "toolCall.task.steps.truncated": "Affichage des {count} étapes les plus récentes ; les étapes antérieures sont omises.", "toolCall.renderer.read.detail.offset": "Décalage : {offset}", "toolCall.renderer.read.detail.limit": "Limite : {limit}", diff --git a/packages/ui/src/lib/i18n/messages/he/toolCall.ts b/packages/ui/src/lib/i18n/messages/he/toolCall.ts index f282f619f..ed084ab36 100644 --- a/packages/ui/src/lib/i18n/messages/he/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/he/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "מפרט ספרייה...", "toolCall.renderer.bash.title.timeout": "פסק זמן: {timeout}", + "toolCall.output.truncated": "[הפלט קוצר לצורך תצוגה; יש להעתיק כדי לגשת לפלט המלא]", + "toolCall.output.tooLarge": "הפלט המובנה לא מוצג מכיוון שהוא גדול מדי.", + "toolCall.task.steps.truncated": "מוצגים {count} השלבים האחרונים; שלבים קודמים הושמטו.", "toolCall.renderer.read.detail.offset": "היסט: {offset}", "toolCall.renderer.read.detail.limit": "מגבלה: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/ja/toolCall.ts b/packages/ui/src/lib/i18n/messages/ja/toolCall.ts index 2bcc9c078..f37b580a7 100644 --- a/packages/ui/src/lib/i18n/messages/ja/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/ja/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "ディレクトリ一覧を取得中...", "toolCall.renderer.bash.title.timeout": "タイムアウト: {timeout}", + "toolCall.output.truncated": "[表示用に出力を省略しました。完全な出力にアクセスするにはコピーしてください]", + "toolCall.output.tooLarge": "構造化出力が大きすぎるため表示を省略しました。", + "toolCall.task.steps.truncated": "最新の{count}件の手順を表示しています。以前の手順は省略されています。", "toolCall.renderer.read.detail.offset": "オフセット: {offset}", "toolCall.renderer.read.detail.limit": "上限: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/ne/toolCall.ts b/packages/ui/src/lib/i18n/messages/ne/toolCall.ts index 2d6089fe2..f53cb2fd2 100644 --- a/packages/ui/src/lib/i18n/messages/ne/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/ne/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "डाइरेक्टरी सूचीबद्ध गर्दै...", "toolCall.renderer.bash.title.timeout": "समय समाप्त: {timeout}", + "toolCall.output.truncated": "[प्रदर्शनका लागि आउटपुट छोट्याइएको छ; पूर्ण आउटपुटका लागि प्रतिलिपि गर्नुहोस्]", + "toolCall.output.tooLarge": "संरचित आउटपुट धेरै ठूलो भएकाले प्रदर्शन गरिएको छैन।", + "toolCall.task.steps.truncated": "पछिल्ला {count} चरणहरू देखाइँदैछन्; पुराना चरणहरू हटाइएका छन्।", "toolCall.renderer.read.detail.offset": "अफसेट: {offset}", "toolCall.renderer.read.detail.limit": "सीमा: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/ru/toolCall.ts b/packages/ui/src/lib/i18n/messages/ru/toolCall.ts index ddf2ddd06..af6fdb103 100644 --- a/packages/ui/src/lib/i18n/messages/ru/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/ru/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "Просмотр каталога…", "toolCall.renderer.bash.title.timeout": "Таймаут: {timeout}", + "toolCall.output.truncated": "[Вывод сокращён для отображения; скопируйте его для доступа к полному выводу]", + "toolCall.output.tooLarge": "Структурированный вывод не отображается, поскольку он слишком большой.", + "toolCall.task.steps.truncated": "Показаны последние {count} шагов; более ранние шаги опущены.", "toolCall.renderer.read.detail.offset": "Смещение: {offset}", "toolCall.renderer.read.detail.limit": "Лимит: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts index 166ee30d9..dbda27479 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "正在列出目录...", "toolCall.renderer.bash.title.timeout": "超时:{timeout}", + "toolCall.output.truncated": "[输出已截断以便显示;复制即可访问完整输出]", + "toolCall.output.tooLarge": "结构化输出过大,已省略显示。", + "toolCall.task.steps.truncated": "正在显示最近的 {count} 个步骤;更早的步骤已省略。", "toolCall.renderer.read.detail.offset": "偏移:{offset}", "toolCall.renderer.read.detail.limit": "限制:{limit}", diff --git a/packages/ui/src/lib/message-render-cache.ts b/packages/ui/src/lib/message-render-cache.ts new file mode 100644 index 000000000..a33303874 --- /dev/null +++ b/packages/ui/src/lib/message-render-cache.ts @@ -0,0 +1,130 @@ +interface MessageCacheItem { + messageId: string +} + +export interface MessageRenderCache { + messageItems: Map + toolItems: Map + messageBlocks: Map +} + +const renderCaches = new Map() + +export const REASONING_RENDER_CHARACTER_LIMIT = 10_000 +export const REASONING_RENDER_NODE_LIMIT = 1_000 +export const REASONING_TITLE_CHARACTER_LIMIT = 384 + +interface TraversalCursor { + array: unknown[] + index: number +} + +function isTraversalCursor(item: unknown): item is TraversalCursor { + if (!item || typeof item !== "object") return false + const cursor = item as Partial + return Array.isArray(cursor.array) && typeof cursor.index === "number" +} + +function extractReasoningSource(source: unknown, characterLimit: number, nodeLimit: number): string { + const pieces: string[] = [] + const stack: unknown[] = [source] + const seen = new WeakSet() + let characters = 0 + let visited = 0 + + while (stack.length > 0 && characters < characterLimit && visited < nodeLimit) { + const item = stack.pop() + visited += 1 + + if (isTraversalCursor(item)) { + if (item.index >= item.array.length) continue + stack.push({ array: item.array, index: item.index + 1 }, item.array[item.index]) + continue + } + + if (typeof item === "string") { + const separatorLength = pieces.length > 0 ? 1 : 0 + const available = characterLimit - characters - separatorLength + if (available <= 0) break + const candidate = item.slice(0, available) + if (/\S/.test(candidate)) { + pieces.push(candidate) + characters += candidate.length + separatorLength + } + if (candidate.length < item.length) break + continue + } + + if (!item || typeof item !== "object" || seen.has(item)) continue + seen.add(item) + + if (Array.isArray(item)) { + stack.push({ array: item, index: 0 }) + continue + } + + const segment = item as { text?: unknown; value?: unknown; content?: unknown } + stack.push(segment.content, segment.value, segment.text) + } + + return pieces.join("\n") +} + +function extractReasoningText(part: unknown, characterLimit: number, nodeLimit: number): string { + const reasoning = part as { text?: unknown; content?: unknown } | null + if (!reasoning || typeof reasoning !== "object") return "" + const text = extractReasoningSource(reasoning.text, characterLimit, nodeLimit) + return text || extractReasoningSource(reasoning.content, characterLimit, nodeLimit) +} + +export function extractReasoningTextForRender(part: unknown): string { + return extractReasoningText(part, REASONING_RENDER_CHARACTER_LIMIT, REASONING_RENDER_NODE_LIMIT) +} + +export function extractReasoningTextForCopy(part: unknown): string { + return extractReasoningText(part, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY) +} + +export function extractReasoningTitleForRender(text: string): string { + const bounded = text.slice(0, REASONING_TITLE_CHARACTER_LIMIT) + const firstLine = bounded.split(/\r?\n/).find((line) => line.trim().length > 0)?.trim() ?? "" + return firstLine.match(/^\*\*([^*]+)\*\*/)?.[1]?.trim() ?? "" +} + +function makeSessionCacheKey(instanceId: string, sessionId: string) { + return `${instanceId}:${sessionId}` +} + +export function getSessionMessageRenderCache(instanceId: string, sessionId: string): MessageRenderCache { + const key = makeSessionCacheKey(instanceId, sessionId) + let cache = renderCaches.get(key) + if (!cache) { + cache = { messageItems: new Map(), toolItems: new Map(), messageBlocks: new Map() } + renderCaches.set(key, cache) + } + return cache +} + +export function peekSessionMessageRenderCache(instanceId: string, sessionId: string): MessageRenderCache | undefined { + return renderCaches.get(makeSessionCacheKey(instanceId, sessionId)) +} + +export function clearSessionMessageRenderCache(instanceId: string, sessionId: string): void { + renderCaches.delete(makeSessionCacheKey(instanceId, sessionId)) +} + +export function clearInstanceMessageRenderCaches(instanceId: string): void { + const prefix = `${instanceId}:` + for (const key of renderCaches.keys()) if (key.startsWith(prefix)) renderCaches.delete(key) +} + +export function purgeMessageRenderCache(cache: MessageRenderCache, messageIds: readonly string[]): void { + const removed = new Set(messageIds) + for (const messageId of removed) cache.messageBlocks.delete(messageId) + for (const [key, item] of cache.messageItems) { + if (removed.has(item.messageId)) cache.messageItems.delete(key) + } + for (const [key, item] of cache.toolItems) { + if (removed.has(item.messageId)) cache.toolItems.delete(key) + } +} diff --git a/packages/ui/src/lib/retained-size.test.ts b/packages/ui/src/lib/retained-size.test.ts new file mode 100644 index 000000000..6acb06a4b --- /dev/null +++ b/packages/ui/src/lib/retained-size.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { estimateRetainedBytes, estimateRetainedBytesIncrementally } from "./retained-size.ts" + +test("handles shared buffers and aborts before traversing a lazy tail", async () => { + const buffer = new ArrayBuffer(64) + const references = [buffer, new Uint8Array(buffer), new DataView(buffer)] + assert.equal(estimateRetainedBytes(references), estimateRetainedBytes([null, null, null]) + buffer.byteLength) + + let accessed = false + Object.defineProperty(references, 3, { enumerable: true, get: () => { accessed = true; return "tail" } }) + references.length = 4 + const controller = new AbortController() + const measurement = estimateRetainedBytesIncrementally(references, { signal: controller.signal, yieldEvery: 1 }) + await Promise.resolve() + assert.equal(accessed, false) + controller.abort() + await assert.rejects(measurement, { name: "AbortError" }) +}) diff --git a/packages/ui/src/lib/retained-size.ts b/packages/ui/src/lib/retained-size.ts new file mode 100644 index 000000000..a7bb9f010 --- /dev/null +++ b/packages/ui/src/lib/retained-size.ts @@ -0,0 +1,158 @@ +const arrayBufferByteLength = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, "byteLength")?.get +const arrayBufferResizable = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, "resizable")?.get +const MAP_ENTRY_BYTES = 24 +const SET_ENTRY_BYTES = 16 + +type RetainedChild = { value: unknown; keyBytes: number } + +function bufferSize(value: object): { bytes: number; growable: boolean } | undefined { + try { + return { bytes: arrayBufferByteLength?.call(value) as number, growable: Boolean(arrayBufferResizable?.call(value)) } + } catch { + return undefined + } +} + +export function estimateRetainedBytes(value: unknown, limit = Number.POSITIVE_INFINITY): number { + const seen = new WeakSet() + const pending: unknown[] = [value] + const children: Iterator[] = [] + let total = 0 + while ((pending.length > 0 || children.length > 0) && total <= limit) { + if (pending.length === 0) { + const child = children[children.length - 1]!.next() + if (child.done) { + children.pop() + continue + } + total += child.value.keyBytes + if (total > limit) break + pending.push(child.value.value) + } + const current = pending.pop() + if (typeof current === "string") total += current.length * 2 + 16 + else if (typeof current === "number" || typeof current === "bigint") total += 8 + else if (typeof current === "boolean") total += 4 + else if (current && typeof current === "object") { + if (ArrayBuffer.isView(current)) { + if (seen.has(current.buffer)) continue + seen.add(current.buffer) + const backing = bufferSize(current.buffer as object) + total += backing?.growable ? limit + 1 : backing?.bytes ?? current.byteLength + continue + } + if (seen.has(current)) continue + seen.add(current) + const bytes = bufferSize(current) + if (bytes !== undefined) { + total += bytes.growable ? limit + 1 : bytes.bytes + continue + } + total += Array.isArray(current) + ? 24 + current.length * 8 + : current instanceof Map + ? 32 + current.size * MAP_ENTRY_BYTES + : current instanceof Set + ? 32 + current.size * SET_ENTRY_BYTES + : 32 + if (total <= limit) children.push(objectChildren(current)) + } + } + return total +} + +export function exceedsRetainedByteLimit(value: unknown, limit: number): boolean { + return estimateRetainedBytes(value, limit) > limit +} + +export async function estimateRetainedBytesIncrementally( + value: unknown, + options: { + signal?: AbortSignal + yieldEvery?: number + rootIterable?: boolean + maxBytes?: number + maxNodes?: number + } = {}, +): Promise { + const seen = new WeakSet() + const pending: unknown[] = [] + const children: Iterator[] = [] + const roots = options.rootIterable ? (value as Iterable)[Symbol.iterator]() : undefined + if (!roots) pending.push(value) + const yieldEvery = Math.max(1, options.yieldEvery ?? 500) + const maxBytes = options.maxBytes ?? Number.POSITIVE_INFINITY + const maxNodes = options.maxNodes ?? Number.POSITIVE_INFINITY + let processed = 0 + let total = 0 + + while (pending.length > 0 || children.length > 0 || roots) { + options.signal?.throwIfAborted() + if (pending.length === 0) { + const child = children[children.length - 1]?.next() + if (child && !child.done) { + total += child.value.keyBytes + if (total > maxBytes) return Number.POSITIVE_INFINITY + pending.push(child.value.value) + } else if (child) { + children.pop() + continue + } else { + const next = roots!.next() + if (next.done) break + pending.push(next.value) + } + } + if (++processed > maxNodes) return Number.POSITIVE_INFINITY + if (processed % yieldEvery === 0) { + await new Promise((resolve) => setTimeout(resolve, 0)) + options.signal?.throwIfAborted() + } + const current = pending.pop() + if (typeof current === "string") total += current.length * 2 + 16 + else if (typeof current === "number" || typeof current === "bigint") total += 8 + else if (typeof current === "boolean") total += 4 + else if (current && typeof current === "object") { + if (ArrayBuffer.isView(current)) { + if (seen.has(current.buffer)) continue + seen.add(current.buffer) + const backing = bufferSize(current.buffer as object) + total += backing?.growable ? Number.POSITIVE_INFINITY : backing?.bytes ?? current.byteLength + continue + } + if (seen.has(current)) continue + seen.add(current) + const bytes = bufferSize(current) + if (bytes !== undefined) { + total += bytes.growable ? Number.POSITIVE_INFINITY : bytes.bytes + continue + } + total += Array.isArray(current) + ? 24 + current.length * 8 + : current instanceof Map + ? 32 + current.size * MAP_ENTRY_BYTES + : current instanceof Set + ? 32 + current.size * SET_ENTRY_BYTES + : 32 + children.push(objectChildren(current)) + } + if (total > maxBytes) return Number.POSITIVE_INFINITY + } + options.signal?.throwIfAborted() + return total +} + +function* objectChildren(current: object): Generator { + if (current instanceof Map) { + for (const [key, entry] of current) { + yield { value: key, keyBytes: 0 } + yield { value: entry, keyBytes: 0 } + } + } else if (current instanceof Set) { + for (const entry of current) yield { value: entry, keyBytes: 0 } + } + for (const key in current) { + if (!Object.prototype.hasOwnProperty.call(current, key)) continue + yield { value: (current as Record)[key], keyBytes: key.length * 2 + 8 } + } +} diff --git a/packages/ui/src/lib/session-transcript-lru.test.ts b/packages/ui/src/lib/session-transcript-lru.test.ts new file mode 100644 index 000000000..0ca2d8dc0 --- /dev/null +++ b/packages/ui/src/lib/session-transcript-lru.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { SessionTranscriptMeasurementQueue } from "./session-transcript-measurement.ts" +import { isSessionTranscriptProtected, SessionTranscriptLru, selectTranscriptEvictions, type TranscriptLruEntry } from "./session-transcript-lru.ts" + +const entry = (sessionId: string, bytes: number, lastUsed: number): TranscriptLruEntry => ({ + instanceId: "instance", sessionId, bytes, lastUsed, +}) + +test("measures, accounts, touches, and enforces transcript LRU order", async (context) => { + context.mock.timers.enable({ apis: ["setTimeout"] }) + const evicted: string[] = [] + const lru = new SessionTranscriptLru({ + byteBudget: 12, + isProtected: () => false, + evict: (_instanceId, sessionId) => evicted.push(sessionId), + }) + const queue = new SessionTranscriptMeasurementQueue({ + delayMs: 1, + measure: async () => 6, + account: (instanceId, sessionId, bytes) => lru.account(instanceId, sessionId, bytes), + onError: () => {}, + }) + + for (const sessionId of ["old", "new"]) { + lru.touch("instance", sessionId) + queue.schedule("instance", sessionId) + context.mock.timers.tick(1) + await Promise.resolve() + } + lru.touch("instance", "old") + queue.schedule("instance", "latest") + context.mock.timers.tick(1) + await Promise.resolve() + lru.enforce() + + assert.deepEqual(evicted, ["new"]) +}) + +test("protects visible, live, and form-blocked transcripts despite temporary budget overage", () => { + const entries = [entry("visible", 4, 1), entry("live", 4, 2), entry("form", 4, 3), entry("inactive", 4, 4)] + assert.deepEqual(selectTranscriptEvictions(entries, 1, ({ sessionId }) => isSessionTranscriptProtected( + sessionId === "visible" ? { visible: true } : sessionId === "live" ? { liveMessages: true } : sessionId === "form" ? { questionBlocked: true } : {}, + )).map(({ sessionId }) => sessionId), ["inactive"]) +}) diff --git a/packages/ui/src/lib/session-transcript-lru.ts b/packages/ui/src/lib/session-transcript-lru.ts new file mode 100644 index 000000000..0f04e7f25 --- /dev/null +++ b/packages/ui/src/lib/session-transcript-lru.ts @@ -0,0 +1,130 @@ +export interface TranscriptLruEntry { + instanceId: string + sessionId: string + bytes: number + lastUsed: number +} + +export interface TranscriptProtectionState { + visible?: boolean + loading?: boolean + status?: "idle" | "working" | "compacting" + generationPending?: boolean + liveMessages?: boolean + permissionBlocked?: boolean + questionBlocked?: boolean +} + +export function isSessionTranscriptProtected(state: TranscriptProtectionState): boolean { + return Boolean( + state.visible + || state.loading + || state.status === "working" + || state.status === "compacting" + || state.generationPending + || state.liveMessages + || state.permissionBlocked + || state.questionBlocked, + ) +} + +export function selectTranscriptEvictions( + entries: readonly TranscriptLruEntry[], + byteBudget: number, + isProtected: (entry: TranscriptLruEntry) => boolean, +): TranscriptLruEntry[] { + let retainedBytes = 0 + let unboundedEntries = 0 + for (const entry of entries) { + if (Number.isFinite(entry.bytes)) retainedBytes += entry.bytes + else unboundedEntries += 1 + } + if (unboundedEntries === 0 && retainedBytes <= byteBudget) return [] + + const selected: TranscriptLruEntry[] = [] + for (const entry of [...entries].sort((left, right) => left.lastUsed - right.lastUsed)) { + if (isProtected(entry)) continue + selected.push(entry) + if (Number.isFinite(entry.bytes)) retainedBytes -= entry.bytes + else unboundedEntries -= 1 + if (unboundedEntries === 0 && retainedBytes <= byteBudget) break + } + return selected +} + +interface SessionTranscriptLruOptions { + byteBudget: number + isProtected: (instanceId: string, sessionId: string) => boolean + evict: (instanceId: string, sessionId: string) => void +} + +export class SessionTranscriptLru { + private entries = new Map() + private pendingTouches = new Map() + private sequence = 0 + + constructor(private options: SessionTranscriptLruOptions) {} + + account(instanceId: string, sessionId: string, bytes: number): void { + const key = this.key(instanceId, sessionId) + if (bytes <= 0) { + this.entries.delete(key) + this.pendingTouches.delete(key) + return + } + const current = this.entries.get(key) + const lastUsed = current?.lastUsed ?? this.pendingTouches.get(key) ?? ++this.sequence + this.pendingTouches.delete(key) + this.entries.set(key, { + instanceId, + sessionId, + bytes, + lastUsed, + }) + this.enforce() + } + + touch(instanceId: string, sessionId: string): boolean { + const key = this.key(instanceId, sessionId) + const lastUsed = ++this.sequence + const entry = this.entries.get(key) + if (entry) { + entry.lastUsed = lastUsed + return false + } + const needsAccounting = !this.pendingTouches.has(key) + this.pendingTouches.set(key, lastUsed) + return needsAccounting + } + + forget(instanceId: string, sessionId: string): void { + const key = this.key(instanceId, sessionId) + this.entries.delete(key) + this.pendingTouches.delete(key) + } + + forgetInstance(instanceId: string): void { + for (const [key, entry] of this.entries) { + if (entry.instanceId === instanceId) this.entries.delete(key) + } + for (const key of this.pendingTouches.keys()) { + if (key.startsWith(`${instanceId}\u0000`)) this.pendingTouches.delete(key) + } + } + + enforce(): void { + const evictions = selectTranscriptEvictions( + [...this.entries.values()], + this.options.byteBudget, + (entry) => this.options.isProtected(entry.instanceId, entry.sessionId), + ) + for (const entry of evictions) { + this.entries.delete(this.key(entry.instanceId, entry.sessionId)) + this.options.evict(entry.instanceId, entry.sessionId) + } + } + + private key(instanceId: string, sessionId: string): string { + return `${instanceId}\u0000${sessionId}` + } +} diff --git a/packages/ui/src/lib/session-transcript-measurement.ts b/packages/ui/src/lib/session-transcript-measurement.ts new file mode 100644 index 000000000..993e562cb --- /dev/null +++ b/packages/ui/src/lib/session-transcript-measurement.ts @@ -0,0 +1,97 @@ +type PendingMeasurement = { + revision: number + timer?: ReturnType + controller?: AbortController +} + +type SessionTranscriptMeasurementOptions = { + delayMs: number + measure: (instanceId: string, sessionId: string, signal: AbortSignal) => Promise + account: (instanceId: string, sessionId: string, bytes: number) => void + onError: (instanceId: string, sessionId: string, error: unknown) => void +} + +export class SessionTranscriptMeasurementQueue { + private pending = new Map() + + constructor(private options: SessionTranscriptMeasurementOptions) {} + + schedule(instanceId: string, sessionId: string): void { + const entryKey = this.key(instanceId, sessionId) + const current = this.pending.get(entryKey) + if (current) { + if (current.controller) { + current.controller.abort() + const replacement = { revision: current.revision + 1 } + this.pending.set(entryKey, replacement) + this.arm(instanceId, sessionId, replacement) + return + } + current.revision += 1 + return + } + + const pending = { revision: 1 } + this.pending.set(entryKey, pending) + this.arm(instanceId, sessionId, pending) + } + + cancel(instanceId: string, sessionId: string): void { + const entryKey = this.key(instanceId, sessionId) + const pending = this.pending.get(entryKey) + if (!pending) return + if (pending.timer !== undefined) clearTimeout(pending.timer) + pending.controller?.abort() + this.pending.delete(entryKey) + } + + cancelInstance(instanceId: string): void { + for (const [entryKey, pending] of this.pending) { + if (!entryKey.startsWith(`${instanceId}\u0000`)) continue + if (pending.timer !== undefined) clearTimeout(pending.timer) + pending.controller?.abort() + this.pending.delete(entryKey) + } + } + + private arm(instanceId: string, sessionId: string, pending: PendingMeasurement): void { + pending.timer = setTimeout(() => { + pending.timer = undefined + void this.measure(instanceId, sessionId, pending) + }, this.options.delayMs) + } + + private async measure(instanceId: string, sessionId: string, pending: PendingMeasurement): Promise { + const entryKey = this.key(instanceId, sessionId) + const measuredRevision = pending.revision + const controller = new AbortController() + pending.controller = controller + try { + const bytes = await this.options.measure(instanceId, sessionId, controller.signal) + if (controller.signal.aborted || this.pending.get(entryKey) !== pending) return + if (pending.revision === measuredRevision) { + this.pending.delete(entryKey) + this.options.account(instanceId, sessionId, bytes) + return + } + } catch (error) { + if (!controller.signal.aborted && this.pending.get(entryKey) === pending && pending.revision === measuredRevision) { + this.options.account(instanceId, sessionId, Number.POSITIVE_INFINITY) + try { + this.options.onError(instanceId, sessionId, error) + } catch { + // Accounting is authoritative; error reporting must not undo it. + } + } + } finally { + if (this.pending.get(entryKey) !== pending) return + pending.controller = undefined + if (pending.revision !== measuredRevision) this.arm(instanceId, sessionId, pending) + else this.pending.delete(entryKey) + } + } + + private key(instanceId: string, sessionId: string): string { + return `${instanceId}\u0000${sessionId}` + } +} diff --git a/packages/ui/src/stores/client-state-codec.test.ts b/packages/ui/src/stores/client-state-codec.test.ts index f5d1012cf..6288653ad 100644 --- a/packages/ui/src/stores/client-state-codec.test.ts +++ b/packages/ui/src/stores/client-state-codec.test.ts @@ -47,7 +47,7 @@ describe("client state codec", () => { tabs: [workspace({ type: "instance", kind: undefined, folder: "C:/work/project", occurrence: 1, projectName: "Project", drafts: { session1: "unfinished prompt" }, - scrollSnapshots: { session1: { scrollTop: 120, scrollRatio: 0.5, atBottom: false, updatedAt: 1200 } }, + scrollSnapshots: { session1: { scrollTop: 120, scrollRatio: 0.5, atBottom: false, updatedAt: 1200, windowCursor: "older", newerCursors: [null, "newer"] } }, unseenIdleSince: { session1: 1100, malformed: -1 }, generationRecovery: { session1: "working", session2: "interrupted", malformed: "idle" }, expandedSessionIds: ["session1", "session1", 42], @@ -65,7 +65,7 @@ describe("client state codec", () => { assert.deepEqual({ ...tab.unseenIdleSince }, { session1: 1100 }) assert.deepEqual({ ...tab.generationRecovery }, { session1: "working", session2: "interrupted" }) assert.deepEqual(tab.expandedSessionIds, ["session1"]) - assert.deepEqual(tab.scrollSnapshots.session1, { scrollTop: 120, scrollRatio: 0.5, atBottom: false, updatedAt: 1200 }) + assert.deepEqual(tab.scrollSnapshots.session1, { scrollTop: 120, scrollRatio: 0.5, atBottom: false, updatedAt: 1200, windowCursor: "older", newerCursors: [null, "newer"] }) assert.deepEqual(decoded?.session?.tabs[1], { kind: "sidecar", sidecarId: "docs" }) }) diff --git a/packages/ui/src/stores/client-state-codec.ts b/packages/ui/src/stores/client-state-codec.ts index 1ca551188..881f976e9 100644 --- a/packages/ui/src/stores/client-state-codec.ts +++ b/packages/ui/src/stores/client-state-codec.ts @@ -101,6 +101,15 @@ function normalizeScrollSnapshot(value: unknown, budget: StringBudget): ScrollSn if (anchorKey !== undefined) result.anchorKey = anchorKey if (anchorOffset !== undefined) result.anchorOffset = anchorOffset if (value.followModeType === "following" || value.followModeType === "escaped") result.followModeType = value.followModeType + const windowCursor = value.windowCursor === undefined ? undefined : takeString(value.windowCursor, MAX_ANCHOR_KEY, budget) + if (windowCursor !== undefined) result.windowCursor = windowCursor + if (Array.isArray(value.newerCursors)) { + result.newerCursors = value.newerCursors.slice(-256).flatMap((cursor) => { + if (cursor === null) return [null] + const normalized = takeString(cursor, MAX_ANCHOR_KEY, budget) + return normalized === undefined ? [] : [normalized] + }) + } return result } diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 828539210..4e07e909b 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -35,14 +35,16 @@ import { ConnectionResyncGate } from "./connection-resync-gate" import { serverSettings } from "./preferences" import { reconcileSessionPendingState, + activeSessionId, messagesLoaded, sessions, setSessionPendingForm, setSessionPendingPermission, + invalidateSessionMessageLoad, } from "./session-state" import { setHasInstances } from "./ui" import { messageStoreBus } from "./message-v2/bus" -import { applyOpenCodeDataEvent, destroyOpenCodeData, projectOpenCodeMessages } from "./opencode-data" +import { applyOpenCodeDataEvent, destroyOpenCodeData, finishOpenCodeDataEvent, projectOpenCodeMessages } from "./opencode-data" import { upsertPermissionV2, removePermissionV2, removeMessageV2 } from "./message-v2/bridge" import { clearRepliedPermissions, @@ -260,8 +262,10 @@ const connectionResyncs = new TrailingResyncCoordinator( syncPendingRequests(instanceId), refreshVolatileInstanceState(instanceId), ]) - await Promise.all(Array.from(messagesLoaded().get(instanceId) ?? [], (sessionId) => - loadMessages(instanceId, sessionId, { force: true }))) + const loadedMessages = messagesLoaded().get(instanceId) ?? new Set() + for (const sessionId of loadedMessages) invalidateSessionMessageLoad(instanceId, sessionId) + const activeId = activeSessionId().get(instanceId) + if (activeId && loadedMessages.has(activeId)) await loadMessages(instanceId, activeId, { force: true }) reconcilePendingSessionIndicators(instanceId) }, (instanceId, error) => { @@ -650,13 +654,18 @@ function startInstanceSessionHydration(instanceId: string, force = false): { const worktreeHydration = force ? reloadWorktrees(instanceId) : ensureWorktreesLoaded(instanceId) - const sessions = worktreeHydration.then(async () => { + const workspaceMetadata = worktreeHydration.then(async () => { + const instance = instances().get(instanceId) + if (instance?.client) await loadInstanceMetadata(instance, { force }).catch((error) => { + log.warn("Failed to load project metadata before session hydration", { instanceId, error }) + }) + }) + const sessions = workspaceMetadata.then(async () => { resetSessionPagination(instanceId) await fetchSessions(instanceId).catch((error) => { log.error("Failed to hydrate sessions", { instanceId, error }) }) }) - const workspaceMetadata = worktreeHydration return { sessions, workspaceMetadata } } @@ -1709,6 +1718,7 @@ function handleInstanceInvalidation(instanceId: string, event: Parameters() +const promptDisplayOverrideBytes = new Map() +let retainedBytes = 0 function makeKey(_instanceId: string, sessionId: string, messageId: string): string { return `${sessionId}:${messageId}` @@ -52,15 +58,39 @@ function ensureLoaded(): void { if (persist() && legacyRaw) storage.removeItem(LEGACY_STORAGE_KEY) } catch { promptDisplayOverrides.clear() + promptDisplayOverrideBytes.clear() + retainedBytes = 0 } } function loadStoredEntries(parsed: Record, migrateLegacyKeys: boolean): void { for (const [key, value] of Object.entries(parsed)) { - if (isPromptDisplayMetadata(value)) { - promptDisplayOverrides.set(migrateLegacyKeys ? migrateStoredKey(key) : key, value) - } + if (isPromptDisplayMetadata(value)) setEntry(migrateLegacyKeys ? migrateStoredKey(key) : key, value) + } +} + +function deleteEntry(key: string): boolean { + const bytes = promptDisplayOverrideBytes.get(key) + if (bytes === undefined) return false + retainedBytes -= bytes + promptDisplayOverrideBytes.delete(key) + promptDisplayOverrides.delete(key) + return true +} + +function setEntry(key: string, value: PromptDisplayMetadata): boolean { + const bytes = key.length * 2 + estimateRetainedBytes(value, ENTRY_BYTE_LIMIT) + if (bytes > ENTRY_BYTE_LIMIT) return false + deleteEntry(key) + promptDisplayOverrides.set(key, value) + promptDisplayOverrideBytes.set(key, bytes) + retainedBytes += bytes + while (promptDisplayOverrides.size > ENTRY_LIMIT || retainedBytes > BYTE_LIMIT) { + const oldest = promptDisplayOverrides.keys().next().value + if (oldest === undefined) break + deleteEntry(oldest) } + return promptDisplayOverrides.has(key) } function persist(): boolean { @@ -96,7 +126,13 @@ export function getPromptDisplayOverride( messageId: string, ): PromptDisplayMetadata | undefined { ensureLoaded() - return promptDisplayOverrides.get(makeKey(instanceId, sessionId, messageId)) + const key = makeKey(instanceId, sessionId, messageId) + const value = promptDisplayOverrides.get(key) + if (value) { + promptDisplayOverrides.delete(key) + promptDisplayOverrides.set(key, value) + } + return value } export function setPromptDisplayOverride( @@ -111,10 +147,9 @@ export function setPromptDisplayOverride( if (displayMetadata && isPromptDisplayMetadata(displayMetadata)) { const serialized = JSON.stringify(displayMetadata) if (previous && JSON.stringify(previous) === serialized) return - promptDisplayOverrides.set(key, displayMetadata) + if (!setEntry(key, displayMetadata)) return } else { - if (!promptDisplayOverrides.has(key)) return - promptDisplayOverrides.delete(key) + if (!deleteEntry(key)) return } persist() } @@ -127,14 +162,14 @@ export function movePromptDisplayOverride(instanceId: string, sessionId: string, const newKey = makeKey(instanceId, sessionId, newMessageId) if (oldKey === newKey) return - promptDisplayOverrides.delete(oldKey) - promptDisplayOverrides.set(newKey, nextValue) + if (!setEntry(newKey, nextValue)) return + deleteEntry(oldKey) persist() } export function clearPromptDisplayOverride(instanceId: string, sessionId: string, messageId: string): void { ensureLoaded() - if (!promptDisplayOverrides.delete(makeKey(instanceId, sessionId, messageId))) { + if (!deleteEntry(makeKey(instanceId, sessionId, messageId))) { return } persist() @@ -147,7 +182,7 @@ export function clearPromptDisplayOverridesForSession(instanceId: string, sessio let changed = false for (const key of promptDisplayOverrides.keys()) { if (key.startsWith(stablePrefix) || key.startsWith(legacyPrefix)) { - promptDisplayOverrides.delete(key) + deleteEntry(key) changed = true } } @@ -162,7 +197,7 @@ export function clearPromptDisplayOverridesForInstance(instanceId: string, sessi const shouldDeleteStableKey = sessionIds.some((sessionId) => key.startsWith(`${sessionId}:`)) const shouldDeleteLegacyKey = key.startsWith(`${instanceId}:`) if (shouldDeleteStableKey || shouldDeleteLegacyKey) { - promptDisplayOverrides.delete(key) + deleteEntry(key) changed = true } } @@ -173,4 +208,6 @@ export function clearPromptDisplayOverridesForInstance(instanceId: string, sessi export function resetPromptDisplayOverrideStateForTests(): void { loaded = false promptDisplayOverrides.clear() + promptDisplayOverrideBytes.clear() + retainedBytes = 0 } diff --git a/packages/ui/src/stores/message-v2/bus.test.ts b/packages/ui/src/stores/message-v2/bus.test.ts index 127b0d442..bb3f578b2 100644 --- a/packages/ui/src/stores/message-v2/bus.test.ts +++ b/packages/ui/src/stores/message-v2/bus.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import { messageStoreBus } from "./bus.ts" -import { invalidateSessionMessageLoad, messagesLoaded, setMessagesLoaded } from "../session-state.ts" +import { messagesLoaded, setMessagesLoaded } from "../session-state.ts" describe("message store scroll snapshots", () => { it("seeds an unregistered instance without claiming runtime authority", () => { @@ -67,8 +67,7 @@ describe("message store scroll snapshots", () => { setMessagesLoaded((prev) => new Map(prev).set(instanceId, new Set(["session-1"]))) try { store.restoreScrollSnapshot("session-1", "message-stream", snapshot) - invalidateSessionMessageLoad(instanceId, "session-1") - store.clearSession("session-1", { preserveScroll: true, notify: false }) + store.clearSession("session-1", { preserveScroll: true }) assert.deepEqual(store.getScrollSnapshot("session-1", "message-stream"), snapshot) assert.equal(messagesLoaded().get(instanceId)?.has("session-1") ?? false, false) } finally { diff --git a/packages/ui/src/stores/message-v2/bus.ts b/packages/ui/src/stores/message-v2/bus.ts index 86900cd9f..baefccc7b 100644 --- a/packages/ui/src/stores/message-v2/bus.ts +++ b/packages/ui/src/stores/message-v2/bus.ts @@ -1,6 +1,6 @@ import { createInstanceMessageStore } from "./instance-store" import type { InstanceMessageStore } from "./instance-store" -import { clearCacheForInstance } from "../../lib/global-cache" +import { clearCacheForInstance, clearCacheForSession } from "../../lib/global-cache" import { getLogger } from "../../lib/logger" import type { ScrollSnapshot } from "./types" @@ -16,6 +16,8 @@ class MessageStoreBus { private stores = new Map() private teardownHandlers = new Set<(instanceId: string) => void>() private sessionClearHandlers = new Set<(instanceId: string, sessionId: string) => void>() + private sessionChangeHandlers = new Set<(instanceId: string, sessionId: string) => void>() + private messageRemovalHandlers = new Set<(instanceId: string, sessionId: string, messageIds: readonly string[]) => void>() private scrollSnapshotHandlers = new Set< (instanceId: string, sessionId: string, scope: string, snapshot: ScrollSnapshot) => void >() @@ -30,6 +32,8 @@ class MessageStoreBus { store ?? createInstanceMessageStore(instanceId, { onSessionCleared: (id, sessionId) => this.notifySessionCleared(id, sessionId), + onSessionChanged: (id, sessionId) => this.notifySessionChanged(id, sessionId), + onMessagesRemoved: (id, sessionId, messageIds) => this.notifyMessagesRemoved(id, sessionId, messageIds), onScrollSnapshotChanged: (id, sessionId, scope, snapshot) => this.notifyScrollSnapshotChanged(id, sessionId, scope, snapshot), }) @@ -52,6 +56,7 @@ class MessageStoreBus { } private notifySessionCleared(instanceId: string, sessionId: string) { + clearCacheForSession(instanceId, sessionId) for (const handler of this.sessionClearHandlers) { try { handler(instanceId, sessionId) @@ -61,6 +66,37 @@ class MessageStoreBus { } } + onSessionChanged(handler: (instanceId: string, sessionId: string) => void): () => void { + this.sessionChangeHandlers.add(handler) + return () => this.sessionChangeHandlers.delete(handler) + } + + private notifySessionChanged(instanceId: string, sessionId: string) { + for (const handler of this.sessionChangeHandlers) { + try { + handler(instanceId, sessionId) + } catch (error) { + log.error("Failed to run session change handler", error) + } + } + } + + onMessagesRemoved(handler: (instanceId: string, sessionId: string, messageIds: readonly string[]) => void): () => void { + this.messageRemovalHandlers.add(handler) + return () => this.messageRemovalHandlers.delete(handler) + } + + private notifyMessagesRemoved(instanceId: string, sessionId: string, messageIds: readonly string[]) { + clearCacheForSession(instanceId, sessionId) + for (const handler of this.messageRemovalHandlers) { + try { + handler(instanceId, sessionId, messageIds) + } catch (error) { + log.error("Failed to run message removal handler", error) + } + } + } + onScrollSnapshotChanged( handler: (instanceId: string, sessionId: string, scope: string, snapshot: ScrollSnapshot) => void, ): () => void { diff --git a/packages/ui/src/stores/message-v2/instance-store.test.ts b/packages/ui/src/stores/message-v2/instance-store.test.ts index 49e7d5a0d..b6f129b65 100644 --- a/packages/ui/src/stores/message-v2/instance-store.test.ts +++ b/packages/ui/src/stores/message-v2/instance-store.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import { createInstanceMessageStore } from "./instance-store.ts" +import { getSessionMessageRenderCache, purgeMessageRenderCache } from "../../lib/message-render-cache.ts" describe("message-v2 permission state", () => { it("keeps one permission attachment when a duplicate moves from global to a tool part", () => { @@ -66,6 +67,38 @@ describe("message-v2 todo state", () => { }) describe("message-v2 hydrateMessages vs pending optimistic sends", () => { + it("trims to 200 messages and purges removed render-cache entries", () => { + const instanceId = "window-trim", sessionId = "session" + const cache = getSessionMessageRenderCache(instanceId, sessionId) + const store = createInstanceMessageStore(instanceId, { + onMessagesRemoved: (_instanceId, _sessionId, messageIds) => purgeMessageRenderCache(cache, messageIds), + }) + store.hydrateMessages(sessionId, Array.from({ length: 201 }, (_, index) => ({ + id: `message-${index}`, sessionId, role: "assistant" as const, status: "complete" as const, + }))) + cache.messageBlocks.set("message-0", {}) + cache.messageBlocks.set("message-200", {}) + + store.trimSessionMessages(sessionId, 200) + + assert.equal(store.getSessionMessageIds(sessionId).length, 200) + assert.equal(store.getSessionMessageIds(sessionId).includes("message-0"), false) + assert.deepEqual([...cache.messageBlocks.keys()], ["message-200"]) + store.clearInstance() + }) + + it("clears pending parts omitted by authoritative hydration", () => { + const store = createInstanceMessageStore("pending-cleanup") + store.hydrateMessages("session-1", [{ id: "old", sessionId: "session-1", role: "assistant", status: "complete" }]) + store.bufferPendingPart({ messageId: "old", sessionId: "session-1", part: { type: "text", text: "pending" } as any, receivedAt: 1 }) + + store.hydrateMessages("session-1", [{ id: "current", sessionId: "session-1", role: "user", status: "complete" }]) + + assert.equal(store.state.pendingParts.old, undefined) + assert.deepEqual(store.getSessionMessageIds("session-1"), ["current"]) + store.clearInstance() + }) + it("keeps an in-flight pending 'sending' message visible when a force reload snapshot doesn't include it yet", () => { const store = createInstanceMessageStore("instance-1") store.addOrUpdateSession({ id: "session-1" }) diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index 7a0f7d24a..d7c0e5875 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -2,6 +2,7 @@ import { batch } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import type { SetStoreFunction } from "solid-js/store" import { getLogger } from "../../lib/logger" +import { getCacheRetainedEntriesForSession } from "../../lib/global-cache" import { clearPromptDisplayOverride, clearPromptDisplayOverridesForInstance, @@ -12,7 +13,10 @@ import { } from "../message-prompt-display" import type { ClientPart, MessageInfo } from "../../types/message" import { mergePermissionRequest } from "../../types/permission" -import { clearRecordDisplayCacheForMessages } from "./record-display-cache" +import { clearRecordDisplayCacheForInstance, clearRecordDisplayCacheForMessages, getRecordDisplayCacheEntries } from "./record-display-cache" +import { estimateRetainedBytes, estimateRetainedBytesIncrementally } from "../../lib/retained-size" +import { clearInstanceMessageRenderCaches, clearSessionMessageRenderCache, peekSessionMessageRenderCache } from "../../lib/message-render-cache" +import type { MessageWindowState } from "./message-window" import { shouldSkipPendingRequestUpsert } from "./pending-request-dedupe" import type { InstanceMessageState, @@ -34,6 +38,8 @@ const storeLog = getLogger("session") interface MessageStoreHooks { onSessionCleared?: (instanceId: string, sessionId: string) => void + onSessionChanged?: (instanceId: string, sessionId: string) => void + onMessagesRemoved?: (instanceId: string, sessionId: string, messageIds: readonly string[]) => void onScrollSnapshotChanged?: (instanceId: string, sessionId: string, scope: string, snapshot: ScrollSnapshot) => void } @@ -73,6 +79,51 @@ function ensurePartId(messageId: string, part: ClientPart, index: number): strin } const PENDING_PART_MAX_AGE_MS = 30_000 +const PENDING_PARTS_PER_MESSAGE_LIMIT = 100 +const PENDING_PARTS_PER_SESSION_LIMIT = 100 +const PENDING_PARTS_GLOBAL_LIMIT = 500 +const PENDING_PART_MAX_RETAINED_BYTES = 1024 * 1024 +const PENDING_PARTS_PER_SESSION_BYTE_LIMIT = 4 * 1024 * 1024 +const PENDING_PARTS_GLOBAL_BYTE_LIMIT = 8 * 1024 * 1024 +const DROPPED_PENDING_MESSAGE_LIMIT = 500 +const MAX_TRANSCRIPT_MEASUREMENT_BYTES = 64 * 1024 * 1024 +const MAX_TRANSCRIPT_MEASUREMENT_NODES = 500_000 +const pendingPartRetainedBytes = Symbol("pendingPartRetainedBytes") +const pendingPartBudgetId = Symbol("pendingPartBudgetId") +let nextPendingPartBudgetId = 0 +const pendingPartBudgetEntries = new Map boolean + remove: () => void +}>() +type SizedPendingPartEntry = PendingPartEntry & { [pendingPartRetainedBytes]?: number; [pendingPartBudgetId]?: number } + +function getPendingPartRetainedBytes(entry: PendingPartEntry): number { + return (entry as SizedPendingPartEntry)[pendingPartRetainedBytes] + ?? estimateRetainedBytes(entry, PENDING_PART_MAX_RETAINED_BYTES) +} + +function forgetPendingPartBudgetEntry(entry: PendingPartEntry): void { + const id = (entry as SizedPendingPartEntry)[pendingPartBudgetId] + if (id !== undefined) pendingPartBudgetEntries.delete(id) +} + +function enforceGlobalPendingPartBudget(): void { + for (const [id, entry] of pendingPartBudgetEntries) if (!entry.isRetained()) pendingPartBudgetEntries.delete(id) + let bytes = 0 + for (const entry of pendingPartBudgetEntries.values()) bytes += entry.bytes + if (bytes <= PENDING_PARTS_GLOBAL_BYTE_LIMIT) return + const oldest = [...pendingPartBudgetEntries.entries()].sort((left, right) => + left[1].receivedAt - right[1].receivedAt || left[0] - right[0]) + for (const [id, entry] of oldest) { + if (bytes <= PENDING_PARTS_GLOBAL_BYTE_LIMIT) break + pendingPartBudgetEntries.delete(id) + bytes -= entry.bytes + entry.remove() + } +} function clonePart(part: ClientPart): ClientPart { // Cloning is intentionally disabled; message parts @@ -252,6 +303,13 @@ export interface InstanceMessageStore { getLastCompactionMessageIndex: (sessionId: string) => number getMessage: (messageId: string) => MessageRecord | undefined getLatestTodoSnapshot: (sessionId: string) => LatestTodoSnapshot | undefined + hasPendingSends: (sessionId: string) => boolean + setMessageWindow: (sessionId: string, window: MessageWindowState) => void + getMessageWindow: (sessionId: string) => MessageWindowState | undefined + trimSessionMessages: (sessionId: string, limit: number) => void + estimateSessionRetainedBytes: (sessionId: string, signal?: AbortSignal) => Promise + hasLiveSessionMessages: (sessionId: string) => boolean + evictSessionTranscript: (sessionId: string) => void clearSession: (sessionId: string, options?: { preserveScroll?: boolean; notify?: boolean }) => void clearScrollSnapshots: () => void clearInstance: () => void @@ -266,6 +324,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt // Requests awaiting same-ID persistence confirmation. const pendingSendIds = new Set() + const droppedPendingMessageIds = new Set() const optimisticPartIdsByMessage = new Map>() function forgetPendingSend(messageId: string): void { @@ -365,6 +424,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt function bumpSessionRevision(sessionId: string) { if (!sessionId) return setState("sessionRevisions", sessionId, (value = 0) => value + 1) + hooks?.onSessionChanged?.(instanceId, sessionId) } function getSessionRevisionValue(sessionId: string) { @@ -410,6 +470,68 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt return state.usage[sessionId] } + function hasLiveSessionMessages(sessionId: string): boolean { + for (const messageId of state.sessions[sessionId]?.messageIds ?? []) { + const message = state.messages[messageId] + if (pendingSendIds.has(messageId) || message?.status === "sending" || message?.status === "streaming") return true + } + return false + } + + function hasPendingSends(sessionId: string): boolean { + for (const messageId of pendingSendIds) { + if (state.messages[messageId]?.sessionId === sessionId) return true + } + return false + } + + function estimateSessionRetainedBytes(sessionId: string, signal?: AbortSignal): Promise { + const session = state.sessions[sessionId] + const renderCache = peekSessionMessageRenderCache(instanceId, sessionId) + const globalCacheEntries = [...getCacheRetainedEntriesForSession(instanceId, sessionId)] + const globalCacheKeyBytes = globalCacheEntries.reduce((total, entry) => total + entry.keyBytes, 0) + let hasPendingParts = false + for (const messageId in state.pendingParts) { + if (state.pendingParts[messageId]?.some((entry) => entry.sessionId === sessionId)) { + hasPendingParts = true + break + } + } + if ((!session || (session.messageIds.length === 0 && !session.revert)) && !renderCache && !hasPendingParts && globalCacheEntries.length === 0) { + return Promise.resolve(0) + } + function* retainedValues(): Generator { + if (session) yield session + for (const messageId of session?.messageIds ?? []) { + yield state.messages[messageId] + yield messageInfoCache.get(messageId) + yield state.messageInfoVersion[messageId] + yield state.pendingParts[messageId] + } + for (const messageId in state.pendingParts) { + const entries = state.pendingParts[messageId] + if (entries?.some((entry) => entry.sessionId === sessionId) && !state.messages[messageId]) yield entries + } + for (const entry of state.permissions.queue) if (entry.permission.sessionID === sessionId) yield entry + if (session) { + yield state.usage[sessionId] + yield state.sessionRevisions[sessionId] + yield state.lastAssistantMessageIds[sessionId] + yield state.latestTodos[sessionId] + } + yield renderCache + if (session) yield* getRecordDisplayCacheEntries(instanceId, session.messageIds) + for (const entry of globalCacheEntries) yield entry.value + } + if (globalCacheKeyBytes > MAX_TRANSCRIPT_MEASUREMENT_BYTES) return Promise.resolve(Number.POSITIVE_INFINITY) + return estimateRetainedBytesIncrementally(retainedValues(), { + signal, + rootIterable: true, + maxBytes: MAX_TRANSCRIPT_MEASUREMENT_BYTES - globalCacheKeyBytes, + maxNodes: MAX_TRANSCRIPT_MEASUREMENT_NODES, + }).then((bytes) => Number.isFinite(bytes) ? bytes + globalCacheKeyBytes : bytes) + } + function ensureSessionEntry(sessionId: string): SessionRecord { const existing = state.sessions[sessionId] if (existing) { @@ -501,6 +623,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt seenInputIds.add(input.id) return true }) + for (const input of dedupedInputs) droppedPendingMessageIds.delete(input.id) const serverIds = dedupedInputs.map((item) => item.id) const serverIdSet = new Set(serverIds) @@ -581,12 +704,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt messageInfoCache.delete(id) forgetPendingSend(id) clearPromptDisplayOverride(instanceId, sessionId, id) + nextPendingParts[id]?.forEach(forgetPendingPartBudgetEntry) delete nextMessages[id] delete nextMessageInfoVersion[id] delete nextPendingParts[id] delete nextPermissionsByMessage[id] }) clearRecordDisplayCacheForMessages(instanceId, omittedIds) + hooks?.onMessagesRemoved?.(instanceId, sessionId, omittedIds) } // A send that reappears under its own id is confirmed — it is no longer in @@ -713,6 +838,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt pendingSendIds.delete(messageId) if (options?.clearOptimisticParts) optimisticPartIdsByMessage.delete(messageId) const record = state.messages[messageId] + if (record) hooks?.onSessionChanged?.(instanceId, record.sessionId) if (!record) return const optimisticPartIds = options?.clearOptimisticParts && record.role === "user" ? record.partIds.filter((id) => clientPartIds?.has(id)) @@ -755,10 +881,15 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt } function retirePendingSends(sessionId: string) { + let changed = false for (const messageId of Array.from(pendingSendIds)) { const record = state.messages[messageId] - if (record?.sessionId === sessionId && record.status === "sent") pendingSendIds.delete(messageId) + if (record?.sessionId === sessionId && record.status === "sent") { + pendingSendIds.delete(messageId) + changed = true + } } + if (changed) hooks?.onSessionChanged?.(instanceId, sessionId) } // Apply an AUTHORITATIVE empty snapshot (server returned zero messages for @@ -787,6 +918,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt clearPromptDisplayOverride(instanceId, sessionId, id) }) clearRecordDisplayCacheForMessages(instanceId, droppedIds) + hooks?.onMessagesRemoved?.(instanceId, sessionId, droppedIds) batch(() => { setState( @@ -872,6 +1004,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt } function upsertMessage(input: MessageUpsertInput) { + const pendingDropRequiresReload = droppedPendingMessageIds.delete(input.id) const normalizedParts = normalizeParts(input.id, input.parts) const shouldBump = Boolean(input.bumpRevision || normalizedParts) const now = Date.now() @@ -915,21 +1048,106 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt flushPendingParts(input.id) recomputeLastAssistantMessageId(input.sessionId) bumpSessionRevision(input.sessionId) + if (pendingDropRequiresReload) hooks?.onSessionCleared?.(instanceId, input.sessionId) + } + + function markPendingPartDropped(messageId: string, sessionId?: string): void { + if (sessionId) { + hooks?.onSessionCleared?.(instanceId, sessionId) + hooks?.onSessionChanged?.(instanceId, sessionId) + return + } + droppedPendingMessageIds.delete(messageId) + droppedPendingMessageIds.add(messageId) + while (droppedPendingMessageIds.size > DROPPED_PENDING_MESSAGE_LIMIT) { + const oldest = droppedPendingMessageIds.values().next().value + if (oldest === undefined) break + droppedPendingMessageIds.delete(oldest) + } } function bufferPendingPart(entry: PendingPartEntry) { - setState("pendingParts", entry.messageId, (list = []) => [...list, entry]) + const sessionId = entry.sessionId ?? (typeof (entry.part as any).sessionID === "string" ? (entry.part as any).sessionID : undefined) + const nextEntry = { ...entry, sessionId } as SizedPendingPartEntry + const retainedBytes = estimateRetainedBytes(nextEntry, PENDING_PART_MAX_RETAINED_BYTES) + if (retainedBytes > PENDING_PART_MAX_RETAINED_BYTES) { + markPendingPartDropped(entry.messageId, sessionId) + return + } + const budgetId = ++nextPendingPartBudgetId + Object.defineProperty(nextEntry, pendingPartRetainedBytes, { value: retainedBytes }) + Object.defineProperty(nextEntry, pendingPartBudgetId, { value: budgetId }) + const changedSessions = new Set() + const droppedMessages = new Map() + if (sessionId) changedSessions.add(sessionId) + setState("pendingParts", produce((draft: Record) => { + const messageEntries = [...(draft[entry.messageId] ?? []), nextEntry] + for (const dropped of messageEntries.slice(0, Math.max(0, messageEntries.length - PENDING_PARTS_PER_MESSAGE_LIMIT))) { + droppedMessages.set(entry.messageId, dropped.sessionId) + } + draft[entry.messageId] = messageEntries.slice(-PENDING_PARTS_PER_MESSAGE_LIMIT) + + const pending: { messageId: string; entry: PendingPartEntry; bytes: number }[] = [] + for (const messageId in draft) { + for (const value of draft[messageId] ?? []) pending.push({ messageId, entry: value, bytes: getPendingPartRetainedBytes(value) }) + } + pending.sort((left, right) => left.entry.receivedAt - right.entry.receivedAt) + + const sessionEntries = pending.filter((value) => value.entry.sessionId === sessionId) + const remove = new Set(pending.slice(0, Math.max(0, pending.length - PENDING_PARTS_GLOBAL_LIMIT))) + for (const value of sessionEntries.slice(0, Math.max(0, sessionEntries.length - PENDING_PARTS_PER_SESSION_LIMIT))) remove.add(value) + let sessionBytes = sessionEntries.reduce((total, value) => total + value.bytes, 0) + for (const value of sessionEntries) { + if (sessionBytes <= PENDING_PARTS_PER_SESSION_BYTE_LIMIT) break + remove.add(value) + sessionBytes -= value.bytes + } + let globalBytes = pending.reduce((total, value) => total + value.bytes, 0) + for (const value of pending) { + if (globalBytes <= PENDING_PARTS_GLOBAL_BYTE_LIMIT) break + remove.add(value) + globalBytes -= value.bytes + } + for (const value of remove) { + forgetPendingPartBudgetEntry(value.entry) + droppedMessages.set(value.messageId, value.entry.sessionId) + if (value.entry.sessionId) changedSessions.add(value.entry.sessionId) + const list = draft[value.messageId] + const index = list?.indexOf(value.entry) ?? -1 + if (index >= 0) list.splice(index, 1) + if (list?.length === 0) delete draft[value.messageId] + } + })) + const isRetained = () => state.pendingParts[entry.messageId]?.some((value) => (value as SizedPendingPartEntry)[pendingPartBudgetId] === budgetId) ?? false + if (isRetained()) { + pendingPartBudgetEntries.set(budgetId, { + instanceId, + bytes: retainedBytes, + receivedAt: entry.receivedAt, + isRetained, + remove: () => { + setState("pendingParts", produce((draft: Record) => { + const list = draft[entry.messageId] + const index = list?.findIndex((value) => (value as SizedPendingPartEntry)[pendingPartBudgetId] === budgetId) ?? -1 + if (index >= 0) list.splice(index, 1) + if (list?.length === 0) delete draft[entry.messageId] + })) + markPendingPartDropped(entry.messageId, sessionId) + }, + }) + enforceGlobalPendingPartBudget() + } + for (const [messageId, droppedSessionId] of droppedMessages) markPendingPartDropped(messageId, droppedSessionId) + for (const changedSessionId of changedSessions) hooks?.onSessionChanged?.(instanceId, changedSessionId) } function clearPendingPartsForMessage(messageId: string) { - setState("pendingParts", (prev) => { - if (!prev[messageId]) { - return prev - } - const next = { ...prev } - delete next[messageId] - return next - }) + const entries = state.pendingParts[messageId] + if (!entries) return + entries.forEach(forgetPendingPartBudgetEntry) + setState("pendingParts", produce((draft: Record) => { + delete draft[messageId] + })) } function rebindPermissionForPart(messageId: string, partId: string, part: ClientPart) { @@ -982,7 +1200,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt function applyPartUpdate(input: PartUpdateInput) { const message = state.messages[input.messageId] if (!message) { - bufferPendingPart({ messageId: input.messageId, part: input.part, receivedAt: Date.now() }) + bufferPendingPart({ messageId: input.messageId, sessionId: typeof (input.part as any).sessionID === "string" ? (input.part as any).sessionID : undefined, part: input.part, receivedAt: Date.now() }) return } @@ -1095,6 +1313,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt if (!sessionIds.size && fallbackSessionId) sessionIds.add(fallbackSessionId) clearRecordDisplayCacheForMessages(instanceId, [messageId]) + sessionIds.forEach((sessionId) => hooks?.onMessagesRemoved?.(instanceId, sessionId, [messageId])) batch(() => { sessionIds.forEach((sessionId) => { @@ -1406,6 +1625,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt }) removedIds.forEach((id) => messageInfoCache.delete(id)) + clearRecordDisplayCacheForMessages(instanceId, removedIds) + hooks?.onMessagesRemoved?.(instanceId, sessionId, removedIds) setState("pendingParts", (prev) => { const next = { ...prev } @@ -1428,7 +1649,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt }) recomputeLastAssistantMessageId(sessionId, keptIds) - bumpSessionRevision(sessionId) } function setSessionRevert(sessionId: string, revert?: SessionRecord["revert"] | null) { @@ -1438,6 +1658,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt pruneMessagesAfterRevert(sessionId, revert.messageID) } setState("sessions", sessionId, "revert", revert ?? null) + bumpSessionRevision(sessionId) } function getSessionRevert(sessionId: string) { @@ -1465,17 +1686,39 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt return state.scrollState[key] } - function clearSession(sessionId: string, options?: { preserveScroll?: boolean; notify?: boolean }) { + function setMessageWindow(sessionId: string, window: MessageWindowState) { + ensureSessionEntry(sessionId) + setState("sessions", sessionId, "messageWindow", window) + } + + function getMessageWindow(sessionId: string) { + return state.sessions[sessionId]?.messageWindow + } + + function trimSessionMessages(sessionId: string, limit: number) { + const ids = state.sessions[sessionId]?.messageIds ?? [] + const overflow = ids.length - Math.max(1, Math.floor(limit)) + for (const messageId of ids.slice(0, Math.max(0, overflow))) removeMessage(messageId, sessionId) + } + + function clearSession(sessionId: string, options?: { preserveScroll?: boolean; preservePromptDisplayOverrides?: boolean; notify?: boolean }) { if (!sessionId) return - clearPromptDisplayOverridesForSession(instanceId, sessionId) + if (!options?.preservePromptDisplayOverrides) clearPromptDisplayOverridesForSession(instanceId, sessionId) const messageIds = Object.values(state.messages) .filter((record) => record.sessionId === sessionId) .map((record) => record.id) + const messageIdSet = new Set(messageIds) + for (const [messageId, entries] of Object.entries(state.pendingParts)) { + for (const entry of entries) { + if (messageIdSet.has(messageId) || entry.sessionId === sessionId) forgetPendingPartBudgetEntry(entry) + } + } storeLog.info("Clearing session data", { instanceId, sessionId, messageCount: messageIds.length }) clearRecordDisplayCacheForMessages(instanceId, messageIds) + clearSessionMessageRenderCache(instanceId, sessionId) messageIds.forEach((id) => forgetPendingSend(id)) batch(() => { @@ -1493,13 +1736,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt messageIds.forEach((id) => messageInfoCache.delete(id)) - setState("pendingParts", (prev) => { - const next = { ...prev } - messageIds.forEach((id) => { - if (next[id]) delete next[id] - }) - return next - }) + setState("pendingParts", produce((draft: Record) => { + for (const id of messageIds) delete draft[id] + for (const id in draft) if (draft[id]?.some((entry) => entry.sessionId === sessionId)) delete draft[id] + })) setState("permissions", "byMessage", (prev) => { const next = { ...prev } @@ -1559,9 +1799,17 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt if (options?.notify !== false) hooks?.onSessionCleared?.(instanceId, sessionId) } + function evictSessionTranscript(sessionId: string) { + clearSession(sessionId, { preserveScroll: true, preservePromptDisplayOverrides: true }) + } + function clearInstance() { + for (const [id, entry] of pendingPartBudgetEntries) if (entry.instanceId === instanceId) pendingPartBudgetEntries.delete(id) + droppedPendingMessageIds.clear() clearPromptDisplayOverridesForInstance(instanceId, Object.keys(state.sessions)) + clearRecordDisplayCacheForInstance(instanceId) + clearInstanceMessageRenderCaches(instanceId) messageInfoCache.clear() pendingSendIds.clear() optimisticPartIdsByMessage.clear() @@ -1613,6 +1861,13 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt getLastCompactionMessageIndex, getMessage: (messageId: string) => state.messages[messageId], getLatestTodoSnapshot, + hasPendingSends, + setMessageWindow, + getMessageWindow, + trimSessionMessages, + estimateSessionRetainedBytes, + hasLiveSessionMessages, + evictSessionTranscript, clearSession, clearScrollSnapshots, clearInstance, diff --git a/packages/ui/src/stores/message-v2/message-window.test.ts b/packages/ui/src/stores/message-v2/message-window.test.ts new file mode 100644 index 000000000..6cdd1cb62 --- /dev/null +++ b/packages/ui/src/stores/message-v2/message-window.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { completeMessageWindow, latestMessageWindow, planNewerMessageWindow, planOlderMessageWindow, preserveMessageWindowCursor } from "./message-window.ts" + +test("moves backward and forward through bounded transcript windows", () => { + const latest = completeMessageWindow(latestMessageWindow(), "older") + const older = planOlderMessageWindow(latest) + assert.deepEqual(older, { cursor: "older", newerCursors: [null] }) + assert.deepEqual(planNewerMessageWindow(older!), latestMessageWindow()) +}) + +test("preserves historical cursors while replacing ordinary scroll coordinates", () => { + assert.deepEqual(preserveMessageWindowCursor( + { scrollTop: 20, atBottom: false }, + { windowCursor: "persisted", newerCursors: [null] }, + { cursor: "current", newerCursors: [null, "newer"] }, + ), { + scrollTop: 20, + atBottom: false, + windowCursor: "current", + newerCursors: [null, "newer"], + }) +}) diff --git a/packages/ui/src/stores/message-v2/message-window.ts b/packages/ui/src/stores/message-v2/message-window.ts new file mode 100644 index 000000000..b347d51e1 --- /dev/null +++ b/packages/ui/src/stores/message-v2/message-window.ts @@ -0,0 +1,47 @@ +export const SESSION_MESSAGE_WINDOW_LIMIT = 200 + +export interface MessageWindowState { + cursor?: string + olderCursor?: string + newerCursors: (string | null)[] +} + +export function latestMessageWindow(): MessageWindowState { + return { newerCursors: [] } +} + +export function restoreMessageWindow(snapshot?: { windowCursor?: string; newerCursors?: (string | null)[] }): MessageWindowState { + return { + cursor: snapshot?.windowCursor, + newerCursors: snapshot?.newerCursors?.filter((cursor) => cursor === null || typeof cursor === "string") ?? [], + } +} + +export function planOlderMessageWindow(current: MessageWindowState): MessageWindowState | null { + if (!current.olderCursor) return null + return { cursor: current.olderCursor, newerCursors: [...current.newerCursors, current.cursor ?? null] } +} + +export function planNewerMessageWindow(current: MessageWindowState): MessageWindowState | null { + if (current.cursor === undefined) return null + const cursor = current.newerCursors.at(-1) + return cursor === undefined || cursor === null + ? latestMessageWindow() + : { cursor, newerCursors: current.newerCursors.slice(0, -1) } +} + +export function completeMessageWindow(window: MessageWindowState, olderCursor?: string): MessageWindowState { + return { ...window, olderCursor } +} + +export function preserveMessageWindowCursor( + snapshot: T, + current: { windowCursor?: string; newerCursors?: (string | null)[] } | undefined, + window: MessageWindowState | undefined, +): T & { windowCursor?: string; newerCursors?: (string | null)[] } { + return { + ...snapshot, + windowCursor: window ? window.cursor : current?.windowCursor, + newerCursors: window ? window.newerCursors : current?.newerCursors, + } +} diff --git a/packages/ui/src/stores/message-v2/record-display-cache.ts b/packages/ui/src/stores/message-v2/record-display-cache.ts index 9e89ee716..88d733ae0 100644 --- a/packages/ui/src/stores/message-v2/record-display-cache.ts +++ b/packages/ui/src/stores/message-v2/record-display-cache.ts @@ -1,10 +1,12 @@ import type { ClientPart } from "../../types/message" +import { extractReasoningTextForRender } from "../../lib/message-render-cache" import type { MessageRecord } from "./types" type ClientPartWithRevision = ClientPart & { revision?: number } export interface RecordDisplayData { orderedParts: ClientPartWithRevision[] + truncated: boolean } interface RecordDisplayCacheEntry { @@ -13,6 +15,7 @@ interface RecordDisplayCacheEntry { } const recordDisplayCache = new Map() +export const MESSAGE_PART_DISPLAY_LIMIT = 200 function makeCacheKey(instanceId: string, messageId: string) { return `${instanceId}:${messageId}` @@ -27,13 +30,26 @@ export function buildRecordDisplayData(instanceId: string, record: MessageRecord const orderedParts: ClientPartWithRevision[] = [] - for (const partId of record.partIds) { + for (let index = 0; index < record.partIds.length && index < MESSAGE_PART_DISPLAY_LIMIT; index += 1) { + const partId = record.partIds[index] const entry = record.parts[partId] if (!entry?.data) continue - orderedParts.push({ ...(entry.data as ClientPart), revision: entry.revision }) + const part = entry.data as ClientPart + if (part.type === "reasoning") { + const time = (part as any).time + orderedParts.push({ + id: part.id, + type: "reasoning", + text: extractReasoningTextForRender(part), + time: time ? { start: time.start, end: time.end, created: time.created } : undefined, + revision: entry.revision, + } as ClientPartWithRevision) + continue + } + orderedParts.push({ ...part, revision: entry.revision }) } - const data: RecordDisplayData = { orderedParts } + const data: RecordDisplayData = { orderedParts, truncated: record.partIds.length > MESSAGE_PART_DISPLAY_LIMIT } recordDisplayCache.set(cacheKey, { revision: record.revision, data }) return data } @@ -53,3 +69,10 @@ export function clearRecordDisplayCacheForMessages(instanceId: string, messageId recordDisplayCache.delete(makeCacheKey(instanceId, messageId)) } } + +export function* getRecordDisplayCacheEntries(instanceId: string, messageIds: Iterable): Generator { + for (const messageId of messageIds) { + const entry = recordDisplayCache.get(makeCacheKey(instanceId, messageId)) + if (entry) yield entry + } +} diff --git a/packages/ui/src/stores/message-v2/types.ts b/packages/ui/src/stores/message-v2/types.ts index 5bbe2dec2..624176c28 100644 --- a/packages/ui/src/stores/message-v2/types.ts +++ b/packages/ui/src/stores/message-v2/types.ts @@ -1,6 +1,7 @@ import type { ClientPart } from "../../types/message" import type { PromptDisplayMetadata } from "../../lib/prompt-display-metadata" import type { PermissionRequest } from "../../types/permission" +import type { MessageWindowState } from "./message-window" export type MessageStatus = "sending" | "sent" | "streaming" | "complete" | "error" export type MessageRole = "user" | "assistant" @@ -40,10 +41,12 @@ export interface SessionRecord { updatedAt: number messageIds: string[] revert?: SessionRevertState | null + messageWindow?: MessageWindowState } export interface PendingPartEntry { messageId: string + sessionId?: string part: ClientPart receivedAt: number } @@ -69,6 +72,8 @@ export interface ScrollSnapshot { anchorOffset?: number atBottom: boolean followModeType?: "following" | "escaped" + windowCursor?: string + newerCursors?: (string | null)[] updatedAt: number } diff --git a/packages/ui/src/stores/opencode-data.test.ts b/packages/ui/src/stores/opencode-data.test.ts index 9dcf2a7ee..167a0b3ff 100644 --- a/packages/ui/src/stores/opencode-data.test.ts +++ b/packages/ui/src/stores/opencode-data.test.ts @@ -3,7 +3,7 @@ import { describe, it } from "node:test" import { messageStoreBus } from "./message-v2/bus.ts" import { seedSessionMessagesV2 } from "./message-v2/bridge.ts" import { normalizeSessionMessage } from "./message-v2/normalizers.ts" -import { applyOpenCodeDataEvent, destroyOpenCodeData, projectOpenCodeMessages } from "./opencode-data.ts" +import { applyOpenCodeDataEvent, destroyOpenCodeData, finishOpenCodeDataEvent, projectOpenCodeMessages } from "./opencode-data.ts" describe("OpenCode data projection", () => { it("uses createData to reduce messages, permissions, and forms", () => { @@ -132,4 +132,75 @@ describe("OpenCode data projection", () => { if (messageStoreBus.getInstance(instanceId)) messageStoreBus.unregisterInstance(instanceId) } }) + + it("does not mix live projection into a historical message window", () => { + const instanceId = "opencode-data-historical-window" + const sessionId = "session" + const store = messageStoreBus.getOrCreate(instanceId) + try { + store.setMessageWindow(sessionId, { cursor: "older-page", newerCursors: [null] }) + const data = applyOpenCodeDataEvent(instanceId, "/work", { + id: "inbox-event", + type: "session.inbox.enqueued", + created: 1, + durable: { aggregateID: sessionId, seq: 1, version: 1 }, + data: { + sessionID: sessionId, + inboxID: "live-message", + item: { type: "user", payload: { text: "live" }, delivery: "queue" }, + }, + } as any) + projectOpenCodeMessages(instanceId, sessionId, data) + assert.equal(store.getMessage("live-message"), undefined) + } finally { + destroyOpenCodeData(instanceId) + if (messageStoreBus.getInstance(instanceId)) messageStoreBus.unregisterInstance(instanceId) + } + }) + + it("keeps another session's live reducer after a terminal event", () => { + const instanceId = "opencode-data-session-isolation" + const event = (sessionID: string, type: string, data: Record = {}) => ({ + id: `${sessionID}-${type}`, type, created: 1, + data: { sessionID, assistantMessageID: `${sessionID}-assistant`, ...data }, + } as any) + try { + applyOpenCodeDataEvent(instanceId, "/work", event("active", "session.step.started", { + agent: "build", model: { providerID: "provider", id: "model" }, + })) + const idle = event("idle", "session.idle") + applyOpenCodeDataEvent(instanceId, "/work", idle) + finishOpenCodeDataEvent(instanceId, idle) + applyOpenCodeDataEvent(instanceId, "/work", event("active", "session.text.started")) + const data = applyOpenCodeDataEvent(instanceId, "/work", event("active", "session.text.delta", { ordinal: 0, delta: "kept" })) + projectOpenCodeMessages(instanceId, "active", data) + assert.equal((messageStoreBus.getOrCreate(instanceId).getMessage("active-assistant")?.parts["active-assistant-text-0"]?.data as any)?.text, "kept") + } finally { + destroyOpenCodeData(instanceId) + if (messageStoreBus.getInstance(instanceId)) messageStoreBus.unregisterInstance(instanceId) + } + }) + + it("projects native inbox delivery order", () => { + const instanceId = "opencode-data-delivery-order" + const sessionId = "session" + const apply = (event: any) => { + const data = applyOpenCodeDataEvent(instanceId, "/work", event) + projectOpenCodeMessages(instanceId, sessionId, data) + } + try { + apply({ id: "queued", type: "session.inbox.enqueued", created: 1, data: { + sessionID: sessionId, inboxID: "queued", item: { type: "user", payload: { text: "queued" }, delivery: "queue" }, + } }) + apply({ id: "other", type: "session.inbox.enqueued", created: 2, data: { + sessionID: sessionId, inboxID: "other", item: { type: "user", payload: { text: "other" }, delivery: "queue" }, + } }) + apply({ id: "delivered", type: "session.inbox.delivered", created: 3, data: { sessionID: sessionId, inboxID: "queued" } }) + + assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), ["other", "queued"]) + } finally { + destroyOpenCodeData(instanceId) + if (messageStoreBus.getInstance(instanceId)) messageStoreBus.unregisterInstance(instanceId) + } + }) }) diff --git a/packages/ui/src/stores/opencode-data.ts b/packages/ui/src/stores/opencode-data.ts index 318f4bbec..1e80d575e 100644 --- a/packages/ui/src/stores/opencode-data.ts +++ b/packages/ui/src/stores/opencode-data.ts @@ -9,8 +9,17 @@ import { messageStoreBus } from "./message-v2/bus" const entries = new Map void; dispose: () => void }>() -function ensureData(instanceId: string, directory: string) { - const existing = entries.get(instanceId) +const entryKey = (instanceId: string, sessionId?: string) => `${instanceId}\u0000${sessionId ?? ""}` + +function eventSessionId(event: OpenCodeEvent): string | undefined { + if ("sessionID" in event.data && typeof event.data.sessionID === "string") return event.data.sessionID + if (event.type === "form.created") return event.data.form.sessionID + return undefined +} + +function ensureData(instanceId: string, directory: string, sessionId?: string) { + const key = entryKey(instanceId, sessionId) + const existing = entries.get(key) if (existing) return existing const listeners = new Set<(event: { name: OpenCodeEvent["type"]; details: OpenCodeEvent }) => void>() @@ -41,13 +50,13 @@ function ensureData(instanceId: string, directory: string) { dispose, } }) - entries.set(instanceId, entry) + entries.set(key, entry) return entry } export function applyOpenCodeDataEvent(instanceId: string, directory: string, event: OpenCodeEvent): Data { if (event.type === "server.connected") destroyOpenCodeData(instanceId) - const entry = ensureData(instanceId, directory) + const entry = ensureData(instanceId, directory, eventSessionId(event)) entry.emit(event) return entry.data } @@ -56,8 +65,11 @@ export function projectOpenCodeMessages(instanceId: string, sessionId: string, d const source = data.session.message.list(sessionId) if (!source.length) return const store = messageStoreBus.getOrCreate(instanceId) + if (store.getMessageWindow(sessionId)?.cursor !== undefined) return + const projectedIds: string[] = [] for (const item of source) { const normalized = normalizeSessionMessage(sessionId, item) + projectedIds.push(normalized.info.id) if (normalized.info.role === "user" && normalized.message.parts.length) { store.confirmServerMessage(normalized.info.id, { clearOptimisticParts: true }) } @@ -69,9 +81,30 @@ export function projectOpenCodeMessages(instanceId: string, sessionId: string, d }) for (const part of normalized.message.parts) applyPartUpdateV2(instanceId, part) } + const projected = new Set(projectedIds) + store.addOrUpdateSession({ + id: sessionId, + messageIds: [...store.getSessionMessageIds(sessionId).filter((id) => !projected.has(id)), ...projectedIds], + }) } -export function destroyOpenCodeData(instanceId: string): void { - entries.get(instanceId)?.dispose() - entries.delete(instanceId) +export function finishOpenCodeDataEvent(instanceId: string, event: OpenCodeEvent): void { + if (event.type === "session.idle" || event.type === "session.deleted") { + destroyOpenCodeData(instanceId, eventSessionId(event)) + } +} + +export function destroyOpenCodeData(instanceId: string, sessionId?: string): void { + if (sessionId !== undefined) { + const key = entryKey(instanceId, sessionId) + entries.get(key)?.dispose() + entries.delete(key) + return + } + const prefix = `${instanceId}\u0000` + for (const [key, entry] of entries) { + if (!key.startsWith(prefix)) continue + entry.dispose() + entries.delete(key) + } } diff --git a/packages/ui/src/stores/pty-store.test.ts b/packages/ui/src/stores/pty-store.test.ts deleted file mode 100644 index 32b210d73..000000000 --- a/packages/ui/src/stores/pty-store.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" -import type { Pty } from "@opencode-ai/client" -import { createPtyStore, type PtyApi } from "./pty-store.ts" - -const pty = (id: string, cwd = "/repo"): Pty => ({ - id, - title: id, - command: "npm", - args: ["run", "dev"], - cwd, - status: "running", - pid: 42, -}) - -describe("PTY store", () => { - it("keeps state location-scoped and refreshes only on exact PTY events and reconnect", async () => { - const lists: string[] = [] - const api: PtyApi = { - list: async (directory) => { lists.push(directory); return [pty(directory, directory)] }, - updateTitle: async () => pty("unused"), - remove: async () => {}, - } - const store = createPtyStore(() => api) - await store.load("instance", "/repo") - await store.load("instance", "/repo/worktree") - lists.length = 0 - - await store.refreshForEvent("instance", { type: "session.updated", location: { directory: "/repo" } }) - assert.deepEqual(lists, []) - await store.refreshForEvent("instance", { type: "pty.updated", location: { directory: "/repo/worktree" } }) - assert.deepEqual(lists, ["/repo/worktree"]) - lists.length = 0 - await store.refreshForEvent("instance", { type: "pty.created", data: { info: { cwd: "/repo" } } }) - assert.deepEqual(lists, ["/repo"]) - lists.length = 0 - await store.refreshForEvent("instance", { type: "server.connected" }) - assert.deepEqual(lists.sort(), ["/repo", "/repo/worktree"]) - }) -}) diff --git a/packages/ui/src/stores/pty-store.ts b/packages/ui/src/stores/pty-store.ts deleted file mode 100644 index c76903cfc..000000000 --- a/packages/ui/src/stores/pty-store.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { createSignal, untrack } from "solid-js" -import type { OpenCodeClient, Pty } from "@opencode-ai/client" - -export interface PtyApi { - list(directory: string): Promise - updateTitle(directory: string, ptyId: string, title: string): Promise - remove(directory: string, ptyId: string): Promise -} - -export interface PtyState { - items: Pty[] - loading: boolean - failed: boolean -} - -export interface PtyRefreshEvent { - type: string - location?: { directory?: string } - data?: { info?: { cwd?: string } } -} - -const EMPTY_STATE: PtyState = { items: [], loading: false, failed: false } -const PTY_EVENTS = new Set(["pty.created", "pty.updated", "pty.exited", "pty.deleted", "server.connected"]) - -export function createPtyApi(client: OpenCodeClient): PtyApi { - const location = (directory: string) => ({ directory }) - return { - list: async (directory) => (await client.pty.list({ location: location(directory) })).data, - updateTitle: async (directory, ptyId, title) => ( - await client.pty.update({ ptyID: ptyId, location: location(directory), title }) - ).data, - remove: (directory, ptyId) => client.pty.remove({ ptyID: ptyId, location: location(directory) }), - } -} - -export function createPtyStore(apiForInstance: (instanceId: string) => PtyApi) { - const [states, setStates] = createSignal>(new Map()) - const generations = new Map() - const key = (instanceId: string, directory: string) => `${instanceId}\0${directory}` - - const setState = (stateKey: string, state: PtyState) => { - setStates((current) => new Map(current).set(stateKey, state)) - } - - const readState = (stateKey: string): PtyState => untrack(() => states().get(stateKey) ?? EMPTY_STATE) - - const load = async (instanceId: string, directory: string): Promise => { - if (!instanceId || !directory) return - const stateKey = key(instanceId, directory) - const generation = (generations.get(stateKey) ?? 0) + 1 - generations.set(stateKey, generation) - setState(stateKey, { ...readState(stateKey), loading: true, failed: false }) - try { - const items = await apiForInstance(instanceId).list(directory) - if (generations.get(stateKey) === generation) setState(stateKey, { items, loading: false, failed: false }) - } catch { - if (generations.get(stateKey) === generation) { - setState(stateKey, { ...readState(stateKey), loading: false, failed: true }) - } - } - } - - const refreshForEvent = async (instanceId: string, event: PtyRefreshEvent): Promise => { - if (!PTY_EVENTS.has(event.type)) return - const eventDirectory = event.location?.directory ?? event.data?.info?.cwd - const tracked = Array.from(states().keys()) - .map((stateKey) => stateKey.split("\0") as [string, string]) - .filter(([trackedInstanceId]) => trackedInstanceId === instanceId) - const matching = eventDirectory ? tracked.filter(([, directory]) => sameDirectory(directory, eventDirectory)) : tracked - // Events are already ownership-scoped by the server. Fall back to every tracked - // location when host/WSL path forms differ so an exact PTY event is never missed. - await Promise.all((matching.length ? matching : tracked).map(([, directory]) => load(instanceId, directory))) - } - - const updateTitle = async (instanceId: string, directory: string, ptyId: string, title: string): Promise => { - try { - await apiForInstance(instanceId).updateTitle(directory, ptyId, title) - await load(instanceId, directory) - return true - } catch { - setState(key(instanceId, directory), { ...getState(instanceId, directory), failed: true }) - return false - } - } - - const remove = async (instanceId: string, directory: string, ptyId: string): Promise => { - try { - await apiForInstance(instanceId).remove(directory, ptyId) - await load(instanceId, directory) - return true - } catch { - setState(key(instanceId, directory), { ...getState(instanceId, directory), failed: true }) - return false - } - } - - const getState = (instanceId: string, directory: string): PtyState => states().get(key(instanceId, directory)) ?? EMPTY_STATE - - return { getState, load, refreshForEvent, updateTitle, remove } -} - -function sameDirectory(left: string, right: string): boolean { - const normalize = (value: string) => { - const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "") - return /^[A-Za-z]:\//.test(normalized) || normalized.startsWith("//") ? normalized.toLowerCase() : normalized - } - return normalize(left) === normalize(right) -} diff --git a/packages/ui/src/stores/ptys.ts b/packages/ui/src/stores/ptys.ts deleted file mode 100644 index a8d633dcb..000000000 --- a/packages/ui/src/stores/ptys.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { serverEvents } from "../lib/server-events" -import { getRootClient } from "./opencode-client" -import { createPtyApi, createPtyStore, type PtyRefreshEvent } from "./pty-store" - -const ptyStore = createPtyStore((instanceId) => createPtyApi(getRootClient(instanceId))) - -serverEvents.on("instance.event", (event) => { - if (event.type !== "instance.event") return - if (!event.event.type.startsWith("pty.")) return - void ptyStore.refreshForEvent(event.instanceId, event.event as PtyRefreshEvent) -}) - -serverEvents.on("instance.eventStatus", (event) => { - if (event.type !== "instance.eventStatus" || event.status !== "connected") return - void ptyStore.refreshForEvent(event.instanceId, { type: "server.connected" }) -}) - -export { ptyStore } diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index a5ae38b7f..ea2e5a731 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -10,6 +10,7 @@ import { isSessionBusy } from "./session-status" import { getDefaultModel, isModelValid } from "./session-models" import { updateSessionInfo } from "./message-v2/session-info" import { messageStoreBus } from "./message-v2/bus" +import { SESSION_MESSAGE_WINDOW_LIMIT } from "./message-v2/message-window" import { getLogger } from "../lib/logger" import { clearConversationPlaybackForSession, isConversationModeEnabled } from "./conversation-speech" @@ -221,6 +222,7 @@ async function sendMessage( isEphemeral: true, clientPromptDisplayMetadata: preparedPrompt.displayMetadata, }) + store.trimSessionMessages(sessionId, SESSION_MESSAGE_WINDOW_LIMIT) // Preserve the optimistic bubble only while the prompt request is unresolved. store.markSendPending(messageId) diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index c70898515..a6e572623 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -4,7 +4,7 @@ import { type Session, } from "../types/session" import type { Message } from "../types/message" -import type { LocationRef, SessionInfo as SDKSession, SessionMessagesResponse } from "@opencode-ai/client" +import type { LocationRef, SessionInfo as SDKSession, SessionsResponse } from "@opencode-ai/client" import { instances, reconcilePendingSessionIndicators } from "./instances" import { preferences, setAgentModelPreference } from "./preferences" @@ -19,7 +19,6 @@ import { cancelSessionGenerationAdmissions, markSessionDeletedAuthoritative, getAuthoritativelyDeletedSessionIdsForInstance, - getDescendantSessions, isBlankSession, messagesLoaded, getSessionMessagesLoadError, @@ -27,6 +26,8 @@ import { setAgents, setMessagesLoaded, advanceMessageLoadEpoch, + finishMessageLoad, + getMessageLoadSignal, invalidateSessionMessageLoad, isCurrentMessageLoad, setSessionMessagesLoadError, @@ -61,7 +62,7 @@ import { messageStoreBus } from "./message-v2/bus" import { clearCacheForSession } from "../lib/global-cache" import { getLogger } from "../lib/logger" import { getOpencodeErrorMessage } from "../lib/opencode-api" -import { getRootClient } from "./opencode-client" +import { getRootClient, type OpenCodeClient } from "./opencode-client" import { tGlobal } from "../lib/i18n" import { getWorktrees, @@ -72,6 +73,16 @@ import { } from "./session-list-options" import { getInstanceMetadata } from "./instance-metadata" import { mergeFetchedSessionRuntimeState, resolveAuthoritativeGenerationRecovery } from "./session-generation-recovery" +import { listMessageWindow } from "./session-message-pages" +import { + SESSION_MESSAGE_WINDOW_LIMIT, + completeMessageWindow, + latestMessageWindow, + planNewerMessageWindow, + planOlderMessageWindow, + restoreMessageWindow, + type MessageWindowState, +} from "./message-v2/message-window" import { fetchCommands } from "./commands" import { toRequestLocation } from "./request-locations" @@ -84,6 +95,11 @@ const providerRequestIds = new Map() const sessionPageRequests = new Map>() const MAX_DESCENDANT_SESSION_REQUESTS = 1_000_000 let nextSessionListRequestId = 0 +const MAX_MESSAGE_REVISION_RETRIES = 1 + +function hasInstanceClientAuthority(instanceId: string, client: OpenCodeClient): boolean { + return instances().get(instanceId)?.client === client +} function catalogLocationKey(location: LocationRef): string { return `${location.directory}\0${location.workspaceID ?? ""}` @@ -591,6 +607,7 @@ async function createSession(instanceId: string, agent?: string): Promise { @@ -623,6 +642,7 @@ async function createSession(instanceId: string, agent?: string): Promise { - const next = { ...prev } - next.creatingSession.set(instanceId, false) - return next - }) + if (hasInstanceClientAuthority(instanceId, instanceClient)) { + setLoading((prev) => { + const next = { ...prev } + next.creatingSession.set(instanceId, false) + return next + }) + } } } @@ -694,6 +717,7 @@ async function forkSession( if (!instance || !instance.client) { throw new Error("Instance not ready") } + const instanceClient = instance.client const client = getRootClient(instanceId) @@ -706,6 +730,7 @@ async function forkSession( log.info(`[HTTP] POST /session.fork for instance ${instanceId}`, request) const info = await client.session.fork(request) + if (!hasInstanceClientAuthority(instanceId, instanceClient)) throw new Error("Instance no longer ready") const forkedSession = toClientSessionV2(instanceId, info) setSessions((prev) => { @@ -754,6 +779,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise { - const next = { ...prev } - const deleting = next.deletingSession.get(instanceId) - if (deleting) { - deleting.delete(sessionId) - } - return next - }) + if (hasInstanceClientAuthority(instanceId, instanceClient)) { + setLoading((prev) => { + const next = { ...prev } + const deleting = next.deletingSession.get(instanceId) + if (deleting) { + deleting.delete(sessionId) + } + return next + }) + } } } @@ -845,6 +874,7 @@ async function fetchAgents(instanceId: string, location = getActiveCatalogLocati if (!instance || !instance.client) { throw new Error("Instance not ready") } + const instanceClient = instance.client const rootClient = getRootClient(instanceId) const requestId = (agentRequestIds.get(instanceId) ?? 0) + 1 @@ -877,7 +907,9 @@ async function fetchAgents(instanceId: string, location = getActiveCatalogLocati : undefined, })) - if (agentRequestIds.get(instanceId) !== requestId || catalogLocationKey(getActiveCatalogLocation(instanceId)) !== catalogLocationKey(location)) return false + if (!hasInstanceClientAuthority(instanceId, instanceClient) + || agentRequestIds.get(instanceId) !== requestId + || catalogLocationKey(getActiveCatalogLocation(instanceId)) !== catalogLocationKey(location)) return false setAgents((prev) => { const next = new Map(prev) next.set(instanceId, agentList) @@ -895,6 +927,7 @@ async function fetchProviders(instanceId: string, location = getActiveCatalogLoc if (!instance || !instance.client) { throw new Error("Instance not ready") } + const instanceClient = instance.client const rootClient = getRootClient(instanceId) const requestId = (providerRequestIds.get(instanceId) ?? 0) + 1 @@ -926,7 +959,9 @@ async function fetchProviders(instanceId: string, location = getActiveCatalogLoc })), })) - if (providerRequestIds.get(instanceId) !== requestId || catalogLocationKey(getActiveCatalogLocation(instanceId)) !== catalogLocationKey(location)) return false + if (!hasInstanceClientAuthority(instanceId, instanceClient) + || providerRequestIds.get(instanceId) !== requestId + || catalogLocationKey(getActiveCatalogLocation(instanceId)) !== catalogLocationKey(location)) return false setProviders((prev) => { const next = new Map(prev) next.set(instanceId, providerList) @@ -939,6 +974,15 @@ async function fetchProviders(instanceId: string, location = getActiveCatalogLoc } } +type MessageWindowIntent = "open" | "older" | "newer" | "latest" + +function planMessageWindow(current: MessageWindowState, intent: MessageWindowIntent): MessageWindowState | null { + if (intent === "older") return planOlderMessageWindow(current) + if (intent === "newer") return planNewerMessageWindow(current) + if (intent === "latest") return latestMessageWindow() + return current +} + async function loadMessages( instanceId: string, sessionId: string, @@ -946,24 +990,33 @@ async function loadMessages( force?: boolean skipChildren?: boolean registerInvalidation?: (invalidate: () => void) => void + revisionRetryCount?: number + intent?: MessageWindowIntent }, -): Promise { +): Promise { const force = options?.force ?? false const skipChildren = options?.skipChildren ?? false + const intent = options?.intent ?? "open" + const store = messageStoreBus.getOrCreate(instanceId) + const plannedWindow = planMessageWindow( + store.getMessageWindow(sessionId) ?? restoreMessageWindow(store.getScrollSnapshot(sessionId, "message-stream")), + intent, + ) + if (!plannedWindow) return false const alreadyLoaded = messagesLoaded().get(instanceId)?.has(sessionId) if (alreadyLoaded && !force) { - return + return false } const previousError = getSessionMessagesLoadError(instanceId, sessionId) if (previousError && !force) { - return + return false } const isLoading = loading().loadingMessages.get(instanceId)?.has(sessionId) if (isLoading && !force) { - return + return false } const instance = instances().get(instanceId) @@ -972,6 +1025,7 @@ async function loadMessages( } const client = getRootClient(instanceId) + const instanceClient = instance.client const instanceSessions = sessions().get(instanceId) const session = instanceSessions?.get(sessionId) @@ -980,11 +1034,13 @@ async function loadMessages( } const loadEpoch = advanceMessageLoadEpoch(instanceId, sessionId) + const signal = getMessageLoadSignal(instanceId, sessionId) options?.registerInvalidation?.(() => { if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) invalidateSessionMessageLoad(instanceId, sessionId) }) - const messageRevision = messageStoreBus.getOrCreate(instanceId).getSessionRevision(sessionId) + const messageRevision = store.getSessionRevision(sessionId) let retryAfterRevisionConflict = false + let committed = false setLoading((prev) => { const next = { ...prev } @@ -997,27 +1053,23 @@ async function loadMessages( try { log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId }) - const apiMessages: SessionMessagesResponse["data"] = [] - let cursor: string | undefined - const seenCursors = new Set() - do { - const response: SessionMessagesResponse = await client.message.list({ - sessionID: sessionId, - limit: 200, - ...(cursor ? { cursor } : { order: "asc" }), - }) - apiMessages.push(...response.data) - cursor = response.cursor?.next ?? undefined - if (cursor && seenCursors.has(cursor)) throw new Error(`Repeated message cursor: ${cursor}`) - if (cursor) seenCursors.add(cursor) - } while (cursor) + const windowPage = await listMessageWindow(client, sessionId, { + limit: SESSION_MESSAGE_WINDOW_LIMIT, + cursor: plannedWindow.cursor, + signal, + isAuthoritative: () => hasInstanceClientAuthority(instanceId, instanceClient) + && isCurrentMessageLoad(instanceId, sessionId, loadEpoch) + && Boolean(sessions().get(instanceId)?.has(sessionId)), + }) + const apiMessages = windowPage?.messages ?? null - if (!instances().has(instanceId) + if (!apiMessages + || !hasInstanceClientAuthority(instanceId, instanceClient) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch) - || !sessions().get(instanceId)?.has(sessionId)) return + || !sessions().get(instanceId)?.has(sessionId)) return false if (!Array.isArray(apiMessages)) { - return + return false } const latestSession = sessions().get(instanceId)?.get(sessionId) @@ -1028,7 +1080,7 @@ async function loadMessages( setSessionMessagesLoadError(instanceId, sessionId, null) if (apiMessages.length === 0) { - if (messageStoreBus.getOrCreate(instanceId).getSessionRevision(sessionId) !== messageRevision) { + if (store.getSessionRevision(sessionId) !== messageRevision) { retryAfterRevisionConflict = true } else { // Authoritative empty snapshot: on a forced reconnect load the server @@ -1036,8 +1088,10 @@ async function loadMessages( // before the reconnect (hydrateMessages ignores empty input). Still // in-flight optimistic sends are preserved by the store. if (force) { - messageStoreBus.getOrCreate(instanceId).reconcileEmptyAuthoritativeSnapshot(sessionId) + store.reconcileEmptyAuthoritativeSnapshot(sessionId) } + commitMessageWindow(store, sessionId, completeMessageWindow(plannedWindow, windowPage?.olderCursor)) + committed = true setMessagesLoaded((prev) => { const next = new Map(prev) const loadedSet = next.get(instanceId) || new Set() @@ -1080,16 +1134,16 @@ async function loadMessages( if (!agentName && !providerID && !modelID) { const defaultModel = await getDefaultModel(instanceId, session.agent) - if (!instances().has(instanceId) + if (!hasInstanceClientAuthority(instanceId, instanceClient) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch) - || !sessions().get(instanceId)?.has(sessionId)) return + || !sessions().get(instanceId)?.has(sessionId)) return false agentName = session.agent providerID = defaultModel.providerId modelID = defaultModel.modelId } setSessions((prev) => { - if (!instances().has(instanceId) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return prev + if (!hasInstanceClientAuthority(instanceId, instanceClient) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return prev const next = new Map(prev) const nextInstanceSessions = next.get(instanceId) if (!nextInstanceSessions) return next @@ -1107,10 +1161,13 @@ async function loadMessages( const sessionForV2 = sessions().get(instanceId)?.get(sessionId) ?? { id: sessionId, title: session?.title, parentId: session?.parentId ?? null, revert: session?.revert, } - if (!instances().has(instanceId) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return + if (!hasInstanceClientAuthority(instanceId, instanceClient) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return false if (!seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo, messageRevision)) { retryAfterRevisionConflict = true } else { + store.trimSessionMessages(sessionId, SESSION_MESSAGE_WINDOW_LIMIT) + commitMessageWindow(store, sessionId, completeMessageWindow(plannedWindow, windowPage?.olderCursor)) + committed = true setMessagesLoaded((prev) => { const next = new Map(prev) const loadedSet = next.get(instanceId) || new Set() @@ -1125,6 +1182,7 @@ async function loadMessages( } catch (error) { + if (signal?.aborted || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return false log.error("Failed to load messages:", error) if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) { setSessionMessagesLoadError(instanceId, sessionId, getOpencodeErrorMessage(error, tGlobal("messageSection.loadError.detail"))) @@ -1138,36 +1196,61 @@ async function loadMessages( if (loadingSet) loadingSet.delete(sessionId) return next }) + finishMessageLoad(instanceId, sessionId, loadEpoch) } } - if (retryAfterRevisionConflict && sessions().get(instanceId)?.has(sessionId)) { + const revisionRetryCount = options?.revisionRetryCount ?? 0 + if (retryAfterRevisionConflict + && revisionRetryCount < MAX_MESSAGE_REVISION_RETRIES + && hasInstanceClientAuthority(instanceId, instanceClient) + && sessions().get(instanceId)?.has(sessionId)) { await new Promise((resolve) => setTimeout(resolve, 50)) - if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return + if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return false return loadMessages(instanceId, sessionId, { force: true, skipChildren, registerInvalidation: options?.registerInvalidation, + revisionRetryCount: revisionRetryCount + 1, + intent, }) } - if (!instances().has(instanceId) + if (retryAfterRevisionConflict) { + setSessionMessagesLoadError(instanceId, sessionId, tGlobal("messageSection.loadError.detail")) + return false + } + + if (!hasInstanceClientAuthority(instanceId, instanceClient) || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch) - || !sessions().get(instanceId)?.has(sessionId)) return + || !sessions().get(instanceId)?.has(sessionId)) return false updateSessionInfo(instanceId, sessionId) - if (!skipChildren && session.parentId === null) { - for (const child of getDescendantSessions(instanceId, sessionId)) { - void loadMessages(instanceId, child.id, { skipChildren: true }).catch((error) => - log.error("Failed to load child session messages", { - instanceId, - sessionId: child.id, - parentSessionId: sessionId, - error, - }), - ) - } - } + return committed +} + +function commitMessageWindow(store: ReturnType, sessionId: string, window: MessageWindowState) { + store.setMessageWindow(sessionId, window) + const snapshot = store.getScrollSnapshot(sessionId, "message-stream") + store.setScrollSnapshot(sessionId, "message-stream", { + scrollTop: snapshot?.scrollTop ?? 0, + scrollRatio: snapshot?.scrollRatio, + maxScrollTop: snapshot?.maxScrollTop, + anchorKey: snapshot?.anchorKey, + anchorOffset: snapshot?.anchorOffset, + atBottom: snapshot?.atBottom ?? window.cursor === undefined, + followModeType: snapshot?.followModeType, + windowCursor: window.cursor, + newerCursors: window.newerCursors, + }) +} + +function loadOlderMessages(instanceId: string, sessionId: string): Promise { + return loadMessages(instanceId, sessionId, { force: true, skipChildren: true, intent: "older" }) +} + +function loadNewerMessages(instanceId: string, sessionId: string): Promise { + return loadMessages(instanceId, sessionId, { force: true, skipChildren: true, intent: "newer" }) } export { @@ -1182,6 +1265,8 @@ export { fetchSessions, hydrateRestoredSessionChain, loadMoreSessions, + loadOlderMessages, + loadNewerMessages, searchSessions, forkSession, loadMessages, diff --git a/packages/ui/src/stores/session-message-pages.ts b/packages/ui/src/stores/session-message-pages.ts new file mode 100644 index 000000000..342e8e4bd --- /dev/null +++ b/packages/ui/src/stores/session-message-pages.ts @@ -0,0 +1,42 @@ +import type { SessionMessageInfo as SDKMessage } from "@opencode-ai/client" +import type { OpenCodeClient } from "./opencode-client" + +export interface MessageWindowPage { + messages: SDKMessage[] + olderCursor?: string +} + +const MAX_WINDOW_PAGES = 1_000 + +async function listMessageWindow( + client: OpenCodeClient, + sessionId: string, + options: { limit: number; cursor?: string; signal?: AbortSignal; isAuthoritative?: () => boolean }, +): Promise { + const isAuthoritative = options.isAuthoritative ?? (() => true) + const messages: SDKMessage[] = [] + const seenCursors = new Set() + let cursor = options.cursor + + for (let page = 0; page < MAX_WINDOW_PAGES && messages.length < options.limit; page += 1) { + options.signal?.throwIfAborted() + if (!isAuthoritative()) return null + const remaining = options.limit - messages.length + const response = await client.message.list(cursor + ? { sessionID: sessionId, limit: remaining, cursor } + : { sessionID: sessionId, limit: remaining, order: "desc" }, { signal: options.signal }) + if (!isAuthoritative()) return null + messages.unshift(...response.data.slice(0, remaining).reverse()) + + const nextCursor = response.cursor?.next ?? undefined + if (!nextCursor) return { messages, olderCursor: undefined } + if (seenCursors.has(nextCursor)) throw new Error(`Repeated message cursor for session ${sessionId}`) + seenCursors.add(nextCursor) + cursor = nextCursor + } + + if (messages.length >= options.limit) return { messages, olderCursor: cursor } + throw new Error(`Message window pagination exceeded ${MAX_WINDOW_PAGES} pages for session ${sessionId}`) +} + +export { listMessageWindow } diff --git a/packages/ui/src/stores/session-request-authority.test.ts b/packages/ui/src/stores/session-request-authority.test.ts index 4359eda04..a4fc1091e 100644 --- a/packages/ui/src/stores/session-request-authority.test.ts +++ b/packages/ui/src/stores/session-request-authority.test.ts @@ -179,7 +179,7 @@ describe("session request authority", () => { await loadMessages(instanceId, sessionId) failSecondPage = true await assert.rejects(loadMessages(instanceId, sessionId, { force: true }), /cursor failed/) - assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), ["old-1", "old-2"]) + assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), ["old-2", "old-1"]) assert.equal(messagesLoaded().get(instanceId)?.has(sessionId), true) } finally { cleanup() diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index 1309212f8..d60f9eeee 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -78,6 +78,7 @@ const [messagesLoaded, setMessagesLoaded] = createSignal const [messageLoadErrors, setMessageLoadErrors] = createSignal>>(new Map()) const [sessionListErrors, setSessionListErrors] = createSignal>(new Map()) const messageLoadEpochs = new Map() +const messageLoadControllers = new Map() let nextMessageLoadEpoch = 0 const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal>>(new Map()) const [threadTotalsByInstance, setThreadTotalsByInstance] = createSignal>>(new Map()) @@ -335,8 +336,10 @@ function clearLoadedFlag(instanceId: string, sessionId: string) { function advanceMessageLoadEpoch(instanceId: string, sessionId: string): number { const key = getDraftKey(instanceId, sessionId) + messageLoadControllers.get(key)?.abort() const epoch = ++nextMessageLoadEpoch messageLoadEpochs.set(key, epoch) + messageLoadControllers.set(key, new AbortController()) return epoch } @@ -344,6 +347,15 @@ function isCurrentMessageLoad(instanceId: string, sessionId: string, epoch: numb return messageLoadEpochs.get(getDraftKey(instanceId, sessionId)) === epoch } +function getMessageLoadSignal(instanceId: string, sessionId: string): AbortSignal | undefined { + return messageLoadControllers.get(getDraftKey(instanceId, sessionId))?.signal +} + +function finishMessageLoad(instanceId: string, sessionId: string, epoch: number): void { + if (!isCurrentMessageLoad(instanceId, sessionId, epoch)) return + messageLoadControllers.delete(getDraftKey(instanceId, sessionId)) +} + function clearMessageLoadingFlag(instanceId: string, sessionId: string): void { setLoading((prev) => { const existing = prev.loadingMessages.get(instanceId) @@ -358,13 +370,81 @@ function clearMessageLoadingFlag(instanceId: string, sessionId: string): void { } function invalidateSessionMessageLoad(instanceId: string, sessionId: string): void { - advanceMessageLoadEpoch(instanceId, sessionId) + const key = getDraftKey(instanceId, sessionId) + messageLoadControllers.get(key)?.abort() + messageLoadControllers.delete(key) + messageLoadEpochs.set(key, ++nextMessageLoadEpoch) clearLoadedFlag(instanceId, sessionId) clearMessageLoadingFlag(instanceId, sessionId) } messageStoreBus.onSessionCleared(invalidateSessionMessageLoad) +function clearInstanceSessionState(instanceId: string): void { + if (!instanceId) return + const prefix = `${instanceId}:` + for (const [key, controller] of messageLoadControllers) { + if (!key.startsWith(prefix)) continue + controller.abort() + messageLoadControllers.delete(key) + } + for (const key of messageLoadEpochs.keys()) { + if (key.startsWith(prefix)) messageLoadEpochs.delete(key) + } + for (const key of generationAdmissions.keys()) { + if (key.startsWith(prefix)) generationAdmissions.delete(key) + } + + const deleteInstance = (prev: Map) => { + if (!prev.has(instanceId)) return prev + const next = new Map(prev) + next.delete(instanceId) + return next + } + const deletePrefixed = (prev: Set) => { + const next = new Set([...prev].filter((key) => !key.startsWith(prefix))) + return next.size === prev.size ? prev : next + } + + batch(() => { + setSessions(deleteInstance) + setActiveSessionId(deleteInstance) + setActiveParentSessionId(deleteInstance) + setAgents(deleteInstance) + setProviders(deleteInstance) + setMessagesLoaded(deleteInstance) + setMessageLoadErrors(deleteInstance) + setSessionListErrors(deleteInstance) + setSessionInfoByInstance(deleteInstance) + setThreadTotalsByInstance(deleteInstance) + setExpandedSessions(deleteInstance) + setInstanceIndicatorCounts(deleteInstance) + setSessionPagination(deleteInstance) + setSessionSearch(deleteInstance) + setLoading((prev) => ({ + fetchingSessions: deleteInstance(prev.fetchingSessions), + creatingSession: deleteInstance(prev.creatingSession), + deletingSession: deleteInstance(prev.deletingSession), + loadingMessages: deleteInstance(prev.loadingMessages), + })) + setSessionDraftPrompts((prev) => { + const next = new Map([...prev].filter(([key]) => !key.startsWith(prefix))) + return next.size === prev.size ? prev : next + }) + setAuthoritativeDraftKeys(deletePrefixed) + setAuthoritativelyDeletedSessionKeys(deletePrefixed) + setAuthoritativeSessionExpansionKeys(deletePrefixed) + setAuthoritativeSessionSelectionInstanceIds((prev) => { + if (!prev.has(instanceId)) return prev + const next = new Set(prev) + next.delete(instanceId) + return next + }) + }) +} + +messageStoreBus.onInstanceDestroyed(clearInstanceSessionState) + function getDraftKey(instanceId: string, sessionId: string): string { return `${instanceId}:${sessionId}` } @@ -1210,6 +1290,8 @@ export { setSessionListError, advanceMessageLoadEpoch, isCurrentMessageLoad, + getMessageLoadSignal, + finishMessageLoad, invalidateSessionMessageLoad, setSessionMessagesLoadError, sessionInfoByInstance, diff --git a/packages/ui/src/stores/session-transcript-memory.ts b/packages/ui/src/stores/session-transcript-memory.ts new file mode 100644 index 000000000..4876ce40b --- /dev/null +++ b/packages/ui/src/stores/session-transcript-memory.ts @@ -0,0 +1,94 @@ +import { createEffect, createRoot } from "solid-js" +import { getLogger } from "../lib/logger" +import { onCacheSessionChanged } from "../lib/global-cache" +import { SessionTranscriptMeasurementQueue } from "../lib/session-transcript-measurement" +import { isSessionTranscriptProtected, SessionTranscriptLru } from "../lib/session-transcript-lru" +import { messageStoreBus } from "./message-v2/bus" +import { loading, sessions } from "./session-state" +import { destroyOpenCodeData } from "./opencode-data" + +// Inactive transcript budget. Protected active/live 200-message windows retain authoritative content and may exceed it. +export const SESSION_TRANSCRIPT_BYTE_BUDGET = 64 * 1024 * 1024 + +const log = getLogger("session") +const visible = new Map() +const key = (instanceId: string, sessionId: string) => `${instanceId}\u0000${sessionId}` + +const coordinator = new SessionTranscriptLru({ + byteBudget: SESSION_TRANSCRIPT_BYTE_BUDGET, + isProtected: (instanceId, sessionId) => { + const session = sessions().get(instanceId)?.get(sessionId) + return isSessionTranscriptProtected({ + visible: visible.has(key(instanceId, sessionId)), + loading: loading().loadingMessages.get(instanceId)?.has(sessionId), + status: session?.status, + generationPending: session?.generationAdmissionToken !== undefined || session?.generationRecovery === "pending", + permissionBlocked: session?.pendingPermission, + questionBlocked: session?.pendingForm, + liveMessages: messageStoreBus.getInstance(instanceId)?.hasLiveSessionMessages(sessionId), + }) + }, + evict: (instanceId, sessionId) => { + log.info("Evicting inactive session transcript", { instanceId, sessionId }) + destroyOpenCodeData(instanceId, sessionId) + messageStoreBus.getInstance(instanceId)?.evictSessionTranscript(sessionId) + }, +}) + +const measurements = new SessionTranscriptMeasurementQueue({ + delayMs: 100, + measure: async (instanceId, sessionId, signal) => { + return messageStoreBus.getInstance(instanceId)?.estimateSessionRetainedBytes(sessionId, signal) ?? 0 + }, + account: (instanceId, sessionId, bytes) => coordinator.account(instanceId, sessionId, bytes), + onError: (instanceId, sessionId, error) => { + log.warn("Failed to measure session transcript", { instanceId, sessionId, error }) + }, +}) + +export function accountSessionTranscript(instanceId: string, sessionId: string): void { + measurements.schedule(instanceId, sessionId) +} + +export function touchSessionTranscript(instanceId: string, sessionId: string): void { + coordinator.touch(instanceId, sessionId) + measurements.schedule(instanceId, sessionId) +} + +export function setSessionTranscriptVisible(instanceId: string, sessionId: string, value: boolean): void { + const entryKey = key(instanceId, sessionId) + if (value) { + visible.set(entryKey, (visible.get(entryKey) ?? 0) + 1) + touchSessionTranscript(instanceId, sessionId) + } else { + const count = (visible.get(entryKey) ?? 0) - 1 + if (count > 0) visible.set(entryKey, count) + else visible.delete(entryKey) + measurements.schedule(instanceId, sessionId) + } + coordinator.enforce() +} + +export function reconcileSessionTranscriptBudget(): void { + coordinator.enforce() +} + +createRoot(() => createEffect(() => { + loading() + sessions() + queueMicrotask(() => coordinator.enforce()) +})) + +messageStoreBus.onSessionChanged(accountSessionTranscript) +onCacheSessionChanged(accountSessionTranscript) +messageStoreBus.onSessionCleared((instanceId, sessionId) => { + measurements.cancel(instanceId, sessionId) + coordinator.forget(instanceId, sessionId) +}) +messageStoreBus.onInstanceDestroyed((instanceId) => { + coordinator.forgetInstance(instanceId) + for (const entryKey of visible.keys()) { + if (entryKey.startsWith(`${instanceId}\u0000`)) visible.delete(entryKey) + } + measurements.cancelInstance(instanceId) +}) diff --git a/packages/ui/src/stores/pty-store-reactivity.test.ts b/packages/ui/src/stores/shell-store-reactivity.test.ts similarity index 59% rename from packages/ui/src/stores/pty-store-reactivity.test.ts rename to packages/ui/src/stores/shell-store-reactivity.test.ts index 9a6fd0632..38608f6ae 100644 --- a/packages/ui/src/stores/pty-store-reactivity.test.ts +++ b/packages/ui/src/stores/shell-store-reactivity.test.ts @@ -1,17 +1,13 @@ import assert from "node:assert/strict" import { it } from "node:test" import { createEffect, createRoot } from "solid-js" -import { createPtyStore, type PtyApi } from "./pty-store.ts" +import { createShellStore, type ShellApi } from "./shell-store.ts" -it("does not subscribe a calling effect to internal PTY loading state", async () => { +it("does not subscribe a calling effect to internal shell loading state", async () => { let listCalls = 0 let effectRuns = 0 - const api: PtyApi = { - list: async () => { listCalls += 1; return [] }, - updateTitle: async () => { throw new Error("unused") }, - remove: async () => {}, - } - const store = createPtyStore(() => api) + const api: ShellApi = { list: async () => { listCalls += 1; return [] }, remove: async () => {} } + const store = createShellStore(() => api) let dispose = () => {} await new Promise((resolve) => { diff --git a/packages/ui/src/stores/shell-store.test.ts b/packages/ui/src/stores/shell-store.test.ts new file mode 100644 index 000000000..1af8181e0 --- /dev/null +++ b/packages/ui/src/stores/shell-store.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import type { ShellInfo } from "@opencode-ai/client" +import { createShellStore, type ShellApi } from "./shell-store.ts" + +const shell = (id: string, cwd = "/repo"): ShellInfo => ({ + id, command: "npm run dev", cwd, shell: "sh", file: "/tmp/output", status: "running", pid: 42, metadata: {}, time: { started: 1 }, +}) + +describe("shell store", () => { + it("keeps state location-scoped and refreshes on shell events and reconnect", async () => { + const lists: string[] = [] + const api: ShellApi = { + list: async (directory) => { lists.push(directory); return [shell(directory, directory)] }, + remove: async () => {}, + } + const store = createShellStore(() => api) + await store.load("instance", "/repo") + await store.load("instance", "/repo/worktree") + lists.length = 0 + + await store.refreshForEvent("instance", { type: "pty.created", location: { directory: "/repo" } }) + assert.deepEqual(lists, []) + await store.refreshForEvent("instance", { type: "shell.created", data: { info: { cwd: "/repo" } } }) + assert.deepEqual(lists, ["/repo"]) + lists.length = 0 + await store.refreshForEvent("instance", { type: "server.connected" }) + assert.deepEqual(lists.sort(), ["/repo", "/repo/worktree"]) + }) +}) diff --git a/packages/ui/src/stores/shell-store.ts b/packages/ui/src/stores/shell-store.ts new file mode 100644 index 000000000..a205586f8 --- /dev/null +++ b/packages/ui/src/stores/shell-store.ts @@ -0,0 +1,84 @@ +import { createSignal, untrack } from "solid-js" +import type { OpenCodeClient, ShellInfo } from "@opencode-ai/client" + +export interface ShellApi { + list(directory: string): Promise + remove(directory: string, shellId: string): Promise +} + +export interface ShellState { + items: ShellInfo[] + loading: boolean + failed: boolean +} + +export interface ShellRefreshEvent { + type: string + location?: { directory?: string } + data?: { info?: { cwd?: string } } +} + +const EMPTY_STATE: ShellState = { items: [], loading: false, failed: false } +const SHELL_EVENTS = new Set(["shell.created", "shell.exited", "shell.deleted", "server.connected"]) + +export function createShellApi(client: OpenCodeClient): ShellApi { + const location = (directory: string) => ({ directory }) + return { + list: async (directory) => (await client.shell.list({ location: location(directory) })).data, + remove: (directory, shellId) => client.shell.remove({ id: shellId, location: location(directory) }), + } +} + +export function createShellStore(apiForInstance: (instanceId: string) => ShellApi) { + const [states, setStates] = createSignal>(new Map()) + const generations = new Map() + const key = (instanceId: string, directory: string) => `${instanceId}\0${directory}` + const setState = (stateKey: string, state: ShellState) => setStates((current) => new Map(current).set(stateKey, state)) + const readState = (stateKey: string): ShellState => untrack(() => states().get(stateKey) ?? EMPTY_STATE) + + const load = async (instanceId: string, directory: string): Promise => { + if (!instanceId || !directory) return + const stateKey = key(instanceId, directory) + const generation = (generations.get(stateKey) ?? 0) + 1 + generations.set(stateKey, generation) + setState(stateKey, { ...readState(stateKey), loading: true, failed: false }) + try { + const items = await apiForInstance(instanceId).list(directory) + if (generations.get(stateKey) === generation) setState(stateKey, { items, loading: false, failed: false }) + } catch { + if (generations.get(stateKey) === generation) setState(stateKey, { ...readState(stateKey), loading: false, failed: true }) + } + } + + const refreshForEvent = async (instanceId: string, event: ShellRefreshEvent): Promise => { + if (!SHELL_EVENTS.has(event.type)) return + const eventDirectory = event.location?.directory ?? event.data?.info?.cwd + const tracked = Array.from(states().keys()) + .map((stateKey) => stateKey.split("\0") as [string, string]) + .filter(([trackedInstanceId]) => trackedInstanceId === instanceId) + const matching = eventDirectory ? tracked.filter(([, directory]) => sameDirectory(directory, eventDirectory)) : tracked + await Promise.all((matching.length ? matching : tracked).map(([, directory]) => load(instanceId, directory))) + } + + const remove = async (instanceId: string, directory: string, shellId: string): Promise => { + try { + await apiForInstance(instanceId).remove(directory, shellId) + await load(instanceId, directory) + return true + } catch { + setState(key(instanceId, directory), { ...getState(instanceId, directory), failed: true }) + return false + } + } + + const getState = (instanceId: string, directory: string): ShellState => states().get(key(instanceId, directory)) ?? EMPTY_STATE + return { getState, load, refreshForEvent, remove } +} + +function sameDirectory(left: string, right: string): boolean { + const normalize = (value: string) => { + const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "") + return /^[A-Za-z]:\//.test(normalized) || normalized.startsWith("//") ? normalized.toLowerCase() : normalized + } + return normalize(left) === normalize(right) +} diff --git a/packages/ui/src/stores/shells.ts b/packages/ui/src/stores/shells.ts new file mode 100644 index 000000000..5c9b0de56 --- /dev/null +++ b/packages/ui/src/stores/shells.ts @@ -0,0 +1,17 @@ +import { serverEvents } from "../lib/server-events" +import { getRootClient } from "./opencode-client" +import { createShellApi, createShellStore, type ShellRefreshEvent } from "./shell-store" + +const shellStore = createShellStore((instanceId) => createShellApi(getRootClient(instanceId))) + +serverEvents.on("instance.event", (event) => { + if (event.type !== "instance.event" || !event.event.type.startsWith("shell.")) return + void shellStore.refreshForEvent(event.instanceId, event.event as ShellRefreshEvent) +}) + +serverEvents.on("instance.eventStatus", (event) => { + if (event.type !== "instance.eventStatus" || event.status !== "connected") return + void shellStore.refreshForEvent(event.instanceId, { type: "server.connected" }) +}) + +export { shellStore }